├── .tool-versions ├── .gitignore ├── example ├── index.html ├── server.ts └── client.ts ├── .markdown-doctest-setup.js ├── package.json ├── test └── test.ts ├── src └── index.ts ├── tsconfig.json └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 9.2.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules 3 | lib/ 4 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Github Search 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.markdown-doctest-setup.js: -------------------------------------------------------------------------------- 1 | const cycleDOM = { 2 | makeDOMDriver: () => () => {} 3 | } 4 | 5 | const cycleTime = { 6 | timeDriver: () => {} 7 | } 8 | 9 | const cycleRun = { 10 | run: () => {} 11 | } 12 | 13 | module.exports = { 14 | require: { 15 | 'cycle-remote-data': require('.'), 16 | '@cycle/dom': cycleDOM, 17 | '@cycle/time': cycleTime, 18 | '@cycle/run': cycleRun, 19 | 'xstream': require('xstream') 20 | }, 21 | 22 | globals: { 23 | ...cycleDOM, 24 | 25 | document: {body: {}} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycle-remote-data", 3 | "version": "0.1.0", 4 | "description": "USAGE: common-readme [-r|--repo REPO-NAME] [-l|--license LICENSE]", 5 | "main": "lib/src/index.js", 6 | "typings": "lib/src/index.d.ts", 7 | "scripts": { 8 | "prepare": "tsc", 9 | "test": "mocha --compilers ts:ts-node/register && markdown-doctest" 10 | }, 11 | "files": [ 12 | "lib" 13 | ], 14 | "keywords": [], 15 | "author": "", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@cycle/dom": "^20.1.0", 19 | "@cycle/time": "^0.10.1", 20 | "@types/browserify": "^12.0.33", 21 | "@types/mocha": "^2.2.44", 22 | "@types/node": "^8.0.49", 23 | "browserify": "^14.5.0", 24 | "mocha": "^4.0.1", 25 | "markdown-doctest": "^0.9.1", 26 | "superagent": "^3.6.0", 27 | "ts-node": "^3.3.0", 28 | "tsify": "^3.0.3", 29 | "typescript": "^2.5.3", 30 | "xstream": "^11.0.0", 31 | "@types/superagent": "^3.5.6" 32 | }, 33 | "dependencies": { 34 | "@cycle/http": "^14.8.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/server.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import * as fs from 'fs'; 3 | import * as browserify from 'browserify'; 4 | 5 | const results = [ 6 | { 7 | name: 'test', 8 | value: 'foo1' 9 | }, 10 | { 11 | name: 'zest', 12 | value: 'foo2' 13 | }, 14 | { 15 | name: 'best', 16 | value: 'foo3' 17 | }, 18 | { 19 | name: 'mest', 20 | value: 'foo4' 21 | }, 22 | ] 23 | 24 | const server = http.createServer((req, res) => { 25 | console.log(req.url); 26 | 27 | if (req.url === '/' || req.url === '/index.html') { 28 | res.writeHead(200, { 'Context-Type': 'text/html' }); 29 | res.end(fs.readFileSync(__dirname + '/index.html')); 30 | return; 31 | } 32 | 33 | if (req.url === '/bundle.js') { 34 | const b = browserify('./example/client.ts', {plugin: 'tsify'}); 35 | res.writeHead(200, { 'Context-Type': 'application/javascript' }); 36 | b.bundle().pipe(res); 37 | return; 38 | } 39 | 40 | const q = (req as any).url.slice(2); 41 | 42 | setTimeout(() => { 43 | if (Math.random() > 0.80) { 44 | res.writeHead(401, { 'Content-Type': 'application/json' }); 45 | res.end(JSON.stringify([])); 46 | return; 47 | } 48 | 49 | res.writeHead(200, { 'Content-Type': 'application/json' }); 50 | res.end(JSON.stringify(results.filter(r => r.name.indexOf(q) !== -1))); 51 | }, Math.random() * 3000); 52 | }); 53 | 54 | server.listen(8080); 55 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as superagent from 'superagent'; 3 | import * as http from 'http'; 4 | import { makeRemoteDataDriver, RemoteResponse } from '../src/'; 5 | 6 | 7 | const server = http.createServer(function (req, res) { 8 | if (req.url === '/err') { 9 | res.writeHead(401, { 'Content-Type': 'application/json' }); 10 | res.end(JSON.stringify({})); 11 | 12 | } 13 | 14 | res.writeHead(200, { 'Content-Type': 'application/json' }); 15 | res.end(JSON.stringify({hello: 'world'})); 16 | }); 17 | 18 | describe('remoteDataDriver', () => { 19 | before(() => server.listen(8090)); 20 | after(() => server.close()); 21 | 22 | it('allows fetching remote data', (done) => { 23 | const driver = makeRemoteDataDriver()(); 24 | 25 | function f (remoteData: RemoteResponse) { 26 | return remoteData.when({ 27 | Ok (response) { return {status: 'success ' + response.body} }, 28 | Loading () { return {status: 'loading'} }, 29 | Error () { return {status: 'error'} }, 30 | NotAsked () { return {status: 'not asked'} } 31 | }); 32 | } 33 | 34 | const states = [ 35 | {status: 'loading'}, 36 | {status: 'success hello world'} 37 | ] 38 | 39 | driver.request({method: 'GET', url: 'localhost:8090'}).map(f).take(states.length).addListener({ 40 | next (actual) { 41 | const expected = states.shift(); 42 | 43 | assert.deepEqual(actual.status, (expected as any).status); 44 | }, 45 | error: done, 46 | complete: done 47 | }); 48 | }); 49 | 50 | it('handles errors', (done) => { 51 | const driver = makeRemoteDataDriver()(); 52 | 53 | function f (remoteData: RemoteResponse) { 54 | return remoteData.when({ 55 | Ok (response) { return {status: 'success ' + response.body} }, 56 | Loading () { return {status: 'loading'} }, 57 | Error (error) { return {status: 'error', error} }, 58 | NotAsked () { return {status: 'not asked'} } 59 | }); 60 | } 61 | 62 | const states = [ 63 | {status: 'loading'}, 64 | {status: 'error'} 65 | ] 66 | 67 | driver.request({method: 'GET', url: 'localhost:8090/err'}).map(f).take(states.length).addListener({ 68 | next (actual) { 69 | const expected = states.shift(); 70 | 71 | assert.deepEqual(actual.status, (expected as any).status); 72 | 73 | if (actual.status === 'error') { 74 | assert.equal((actual as any).error.response.request.url, 'localhost:8090/err'); 75 | } 76 | }, 77 | error: done, 78 | complete: done 79 | }); 80 | }); 81 | }); 82 | 83 | -------------------------------------------------------------------------------- /example/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | makeRemoteDataDriver, 3 | rmap, 4 | RemoteDataSource, 5 | RemoteResponse, 6 | NotAsked, 7 | NotAsked$, 8 | RemoteData 9 | } from '../src'; 10 | import { makeDOMDriver, DOMSource, div, input, button, pre } from '@cycle/dom'; 11 | import { timeDriver, TimeSource } from '@cycle/time'; 12 | import { run } from '@cycle/run'; 13 | import xs from 'xstream'; 14 | 15 | interface Sources { 16 | DOM: DOMSource; 17 | Time: TimeSource; 18 | RemoteData: RemoteDataSource; 19 | } 20 | 21 | interface Result { 22 | name: string; 23 | value: string; 24 | } 25 | 26 | function GithubSearch(sources: Sources) { 27 | const query$ = sources.DOM 28 | .select('.search-query') 29 | .events('input') 30 | .map(ev => (ev.target as HTMLInputElement).value) 31 | .remember(); 32 | 33 | const finishedTyping$ = query$.compose(sources.Time.debounce(250)); 34 | 35 | const searchClick$ = sources.DOM.select('.search').events('click'); 36 | 37 | const reload$ = sources.DOM.select('.reload').events('click'); 38 | 39 | const search$ = xs.merge(finishedTyping$, reload$, searchClick$); 40 | 41 | const data$ = search$.map(() => query$.take(1)).flatten(); 42 | 43 | const loadingProgress$ = sources.Time.periodic(300).map(i => i % 3 + 1); 44 | 45 | const remoteData$ = data$ 46 | .map(query => 47 | query === '' 48 | ? NotAsked$ 49 | : sources.RemoteData.request({ url: `/?${query}`, method: 'GET' }) 50 | ) 51 | .flatten(); 52 | 53 | const post$ = remoteData$ 54 | .map(rmap(res => res.body as Result[])) 55 | .startWith(NotAsked); 56 | 57 | return { 58 | DOM: xs.combine(post$, loadingProgress$).map(view) 59 | }; 60 | } 61 | 62 | function view([remotePost, loadingProgress]: [RemoteData, number]) { 63 | return div([ 64 | div('Search:'), 65 | input('.search-query'), 66 | button('.search', 'Search'), 67 | 68 | remotePost.when({ 69 | Loading: progress => loadingView(loadingProgress), 70 | Error: errorView, 71 | Ok: resultsView, 72 | NotAsked: notAskedView 73 | }) 74 | ]); 75 | } 76 | 77 | function errorView() { 78 | return div(['Error loading content', button('.reload', 'Reload')]); 79 | } 80 | 81 | function loadingView(progress: number) { 82 | return div([ 83 | 'Loading' + 84 | Array(progress) 85 | .fill('.') 86 | .join('') 87 | ]); 88 | } 89 | 90 | function notAskedView() { 91 | return div(['Search for something!']); 92 | } 93 | 94 | function resultsView(results: Result[]) { 95 | if (results.length === 0) { 96 | return div('No results found'); 97 | } 98 | 99 | return div(results.map(post => div('.post', post.name + ' - ' + post.value))); 100 | } 101 | 102 | const drivers = { 103 | DOM: makeDOMDriver(document.body), 104 | RemoteData: makeRemoteDataDriver(), 105 | Time: timeDriver 106 | }; 107 | 108 | run(GithubSearch, drivers); 109 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Response, RequestOptions } from '@cycle/http/lib/cjs/interfaces'; 2 | import { optionsToSuperagent } from '@cycle/http/lib/cjs/http-driver'; 3 | import * as superagent from 'superagent'; 4 | import xs, { Listener, MemoryStream } from 'xstream'; 5 | 6 | export interface RemoteDataSource { 7 | request(options: RequestOptions): MemoryStream; 8 | } 9 | 10 | export type RemoteResponse = RemoteData; 11 | 12 | export interface RemoteData { 13 | when(cases: Cases): U; 14 | rmap(f: (t: T) => V): RemoteData; 15 | } 16 | 17 | export interface Cases { 18 | NotAsked: () => U; 19 | Loading: (progress: number) => U; 20 | Error: (err: ResponseError) => U; 21 | Ok: (value: T) => U; 22 | } 23 | 24 | export interface ResponseError extends Error { 25 | response: Response; 26 | } 27 | 28 | export function rmap(f: (t: T) => U): (r: RemoteData) => RemoteData { 29 | return r => r.rmap(f); 30 | } 31 | 32 | export const NotAsked = { 33 | when(cases: Cases): U { 34 | return cases.NotAsked(); 35 | }, 36 | 37 | rmap() { 38 | return NotAsked; 39 | } 40 | }; 41 | 42 | export const NotAsked$ = xs.of(NotAsked).remember(); 43 | 44 | function Loading(progress: number): RemoteData { 45 | const loading = { 46 | when(cases: Cases): U { 47 | return cases.Loading(progress); 48 | }, 49 | 50 | rmap() { 51 | return loading; 52 | } 53 | }; 54 | 55 | return loading; 56 | } 57 | 58 | function ErrorResponse(err: ResponseError): RemoteData { 59 | const error = { 60 | when(cases: Cases): U { 61 | return cases.Error(err); 62 | }, 63 | 64 | rmap() { 65 | return error; 66 | } 67 | }; 68 | 69 | return error; 70 | } 71 | 72 | function Ok(value: T): RemoteData { 73 | return { 74 | when(cases: Cases): U { 75 | return cases.Ok(value); 76 | }, 77 | 78 | rmap(f: (t: T) => V): RemoteData { 79 | return Ok(f(value)); 80 | } 81 | }; 82 | } 83 | 84 | function requestToResponse( 85 | requestOptions: RequestOptions 86 | ): MemoryStream { 87 | let request: superagent.Request; 88 | 89 | return xs.createWithMemory({ 90 | start(listener) { 91 | request = optionsToSuperagent(requestOptions); 92 | 93 | listener.next(Loading(0)); 94 | 95 | if (requestOptions.progress) { 96 | request = request.on('progress', ev => 97 | listener.next(Loading(ev.percent || 0)) 98 | ); 99 | } 100 | 101 | request.end(processResponse(requestOptions, listener)); 102 | }, 103 | 104 | stop() { 105 | request.abort(); 106 | } 107 | }); 108 | } 109 | 110 | function processResponse(request: RequestOptions, listener: Listener) { 111 | return (error: ResponseError | null, response: Response) => { 112 | response.request = request; 113 | 114 | if (error) { 115 | error.response = response; 116 | 117 | listener.next(ErrorResponse(error)); 118 | } else { 119 | listener.next(Ok(response)); 120 | } 121 | 122 | listener.complete(); 123 | } 124 | } 125 | 126 | export function makeRemoteDataDriver() { 127 | return function remoteDataDriver(): RemoteDataSource { 128 | return { request: requestToResponse }; 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "lib": ["ES6", "DOM", "ES5"], /* Specify library files to be included in the compilation: */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 12 | // "outFile": "./", /* Concatenate and emit output to single file. */ 13 | "outDir": "./lib", /* Redirect output structure to the directory. */ 14 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 15 | // "removeComments": true, /* Do not emit comments to output. */ 16 | // "noEmit": true, /* Do not emit outputs. */ 17 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 18 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 19 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 20 | 21 | /* Strict Type-Checking Options */ 22 | "strict": true /* Enable all strict type-checking options. */ 23 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 24 | // "strictNullChecks": true, /* Enable strict null checks. */ 25 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 26 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 27 | 28 | /* Additional Checks */ 29 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 30 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 31 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 32 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 33 | 34 | /* Module Resolution Options */ 35 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 36 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 37 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 38 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 39 | // "typeRoots": [], /* List of folders to include type definitions from. */ 40 | // "types": [], /* Type declaration files to be included in compilation. */ 41 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 42 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 43 | 44 | /* Source Map Options */ 45 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 46 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 47 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 48 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 49 | 50 | /* Experimental Options */ 51 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 52 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cycle-remote-data 2 | 3 | `cycle-remote-data` is a Cycle.js driver for fetching and sending data over HTTP. 4 | 5 | `@cycle/http` is the official HTTP driver for Cycle.js. `cycle-remote-data` attempts to improve over `@cycle/http` in a number of ways. When using `@cycle/http`, it is easy to forget to handle errors, which then crash your application. Successfully handling errors is not easy, and if you are using a type checker can be extra painful. 6 | 7 | Additionally, it is often useful to be able to show feedback in the UI when loading data, or when data loading has not yet started. This is also not always straightforward with `@cycle/http`. 8 | 9 | [krisajenkins](https://github.com/krisajenkins), author of the [Elm library](http://package.elm-lang.org/packages/krisajenkins/remotedata/latest) that inspired this one, has an [excellent blog post](http://blog.jenkster.com/2016/06/how-elm-slays-a-ui-antipattern.html) describing this problem generically. 10 | 11 | With `cycle-remote-data`, you can call `sources.RemoteData.request({url: '/search?q=hi', method: 'GET'})`, which returns a `MemoryStream` of `RemoteData` objects. 12 | 13 | There are four possible states: `Ok`, `Error`, `Loading` and `NotAsked`. When working with `RemoteData` objects, we handle all of these possibilities. 14 | 15 | When using TypeScript in strict mode, the compiler will even catch any cases we fail to handle! 16 | 17 | 18 | ## Install 19 | 20 | ```bash 21 | $ npm install cycle-remote-data 22 | ``` 23 | 24 | ## Usage 25 | 26 | First, we want to import `makeRemoteDataDriver` and add it to our drivers. 27 | 28 | ```js 29 | import { makeRemoteDataDriver } from 'cycle-remote-data'; 30 | 31 | const drivers = { 32 | DOM: makeDOMDriver('.app'), 33 | RemoteData: makeRemoteDataDriver() 34 | } 35 | ``` 36 | 37 | Then, inside of our main, we can use `sources.RemoteData.request` to make requests. 38 | 39 | ```js 40 | function main(sources) { 41 | const results$ = sources.RemoteData.request({ 42 | method: 'GET', 43 | url: 'https://api.github.com/search/repositories?q=cycle' 44 | }); 45 | } 46 | ``` 47 | 48 | If you've used `@cycle/http`, you'll notice some differences. First of all, we can make requests with a method in sources, rather than from our sinks. Secondly, instead of a stream of streams, we're just working with a flat stream of RemoteData states. 49 | 50 | One nice similarity is that `cycle-remote-data` expects requests in the same format as `@cycle/http`, and uses the exact same code under the hood to turn that into a `superagent` request. 51 | 52 | So we have a stream of `RemoteData` states, but what is a `RemoteData`? If we print it out, we see an object with two methods, `when` and `rmap`. 53 | 54 | `RemoteData` is an interface across four possible states, named `Ok`, `Error`, `Loading` and `NotAsked`. Notice that there is no `type` or identifying information present in a `RemoteData` object. 55 | 56 | So how do we work with the data we've loaded? That's where `when` comes into play. 57 | 58 | Let's map over our `result$` and put it into a `view`. 59 | 60 | 61 | ```js 62 | function main(sources) { 63 | const results$ = sources.RemoteData.request({ 64 | method: 'GET', 65 | url: 'https://api.github.com/search/repositories?q=cycle' 66 | }); 67 | 68 | return { 69 | DOM: results$.map(view) 70 | } 71 | } 72 | ``` 73 | 74 | So how do we define our view? 75 | 76 | ```js 77 | function view(remoteData) { 78 | return remoteData.when({ 79 | Ok: response => div(JSON.stringify(response.body)), 80 | Error: () => div('An error occurred loading data...'), 81 | Loading: () => div('Loading...'), 82 | NotAsked: () => div('') 83 | }) 84 | } 85 | ``` 86 | 87 | We can use the `when` method to handle all of the possible cases. Our `RemoteData` will start out `Loading`, and then progress into either `Ok` or `Error`. 88 | 89 | Whenever the `RemoteData` state changes our `results$` will update, and our `when` will be called again. 90 | 91 | You may wonder where `NotAsked` comes into it. `NotAsked` is not a state that `cycle-remote-data` will ever emit, but it can be useful to import `NotAsked` and `.startWith(NotAsked)`. 92 | 93 | This is useful for example when you have a search box, and you want it to say 'Type something to search' when nothing has been searched or it has been cleared. 94 | 95 | The above example does have an ugly part, in that we are working with responses directly in our view. What we need is a way to apply functions to the `Ok` value before calling `when`. 96 | 97 | This is where `rmap` is handy. We could alter our main function to pull the body off of the response. 98 | 99 | ```js 100 | function main(sources) { 101 | const response$ = sources.RemoteData.request({ 102 | method: 'GET', 103 | url: 'https://api.github.com/search/repositories?q=cycle' 104 | }); 105 | 106 | const results$ = response$.map(remoteData => remoteData.rmap(response => response.body)); 107 | 108 | return { 109 | DOM: results$.map(view) 110 | } 111 | } 112 | ``` 113 | 114 | `remoteData.rmap` takes a function that transforms the response. This is why `RemoteData` is actually a generic interface in TypeScript. 115 | 116 | When you call `RemoteData.request`, you're actually getting a `MemoryStream>`, where `Response` comes from `@cycle/http` and has an attached `request`. Calling `.rmap` on the `RemoteData` will return a `RemoteData` with the generic type of the return value of your `rmap` function. 117 | 118 | ## Example 119 | 120 | This is a larger example that includes a `reload` button on errors, uses a `NotAsked` state at the start, and cancels old requests when new searches are made. 121 | 122 | ```js 123 | import {makeRemoteDataDriver, NotAsked, NotAsked$} from 'cycle-remote-data'; 124 | import {makeDOMDriver, div, input, button} from '@cycle/dom'; 125 | import {timeDriver} from '@cycle/time'; 126 | import {run} from '@cycle/run'; 127 | import xs from 'xstream'; 128 | 129 | function GithubSearch(sources) { 130 | const query$ = sources.DOM 131 | .select('.search') 132 | .events('input') 133 | .map(ev => ev.target.value) 134 | .remember(); 135 | 136 | const reload$ = sources.DOM 137 | .select('.reload') 138 | .events('click'); 139 | 140 | const search$ = xs.merge( 141 | query$.compose(sources.Time.debounce(250)), 142 | reload$ 143 | ); 144 | 145 | const searchWithQuery$ = search$.map(() => query$.take(1)).flatten(); 146 | 147 | const post$ = searchWithQuery$ 148 | .map(query => 149 | query === '' 150 | ? NotAsked$ 151 | : sources.RemoteData.request({ 152 | url: `https://api.github.com/search/repositories?q=${query}`, 153 | method: 'GET' 154 | }) 155 | ) 156 | .flatten() 157 | .map(remoteData => remoteData.rmap(response => response.body)) 158 | .startWith(NotAsked); 159 | 160 | return { 161 | DOM: post$.map(view) 162 | } 163 | } 164 | 165 | function view(remotePost) { 166 | return div([ 167 | 'Search github', 168 | input('.search'), 169 | 170 | remotePost.when({ 171 | Loading: loadingView, 172 | Error: errorView, 173 | Ok: postsView, 174 | NotAsked: notAskedView 175 | }) 176 | ]); 177 | } 178 | 179 | function errorView() { 180 | return div([ 181 | 'Error loading content', 182 | button('.reload', "Reload") 183 | ]) 184 | } 185 | 186 | function loadingView() { 187 | return div('Loading...') 188 | } 189 | 190 | function notAskedView() { 191 | return div([ 192 | 'Search for something!' 193 | ]) 194 | } 195 | 196 | function postsView(posts) { 197 | return div([ 198 | JSON.stringify(posts) 199 | ]) 200 | } 201 | 202 | const drivers = { 203 | DOM: makeDOMDriver(document.body), 204 | RemoteData: makeRemoteDataDriver(), 205 | Time: timeDriver 206 | } 207 | 208 | run(GithubSearch, drivers); 209 | ``` 210 | 211 | ## API 212 | 213 | ### `makeRemoteDataDriver()` 214 | 215 | This is a function that returns a remote data driver. The remote data driver is also a function, that takes no arguments and returns a `RemoteDataSource`. 216 | 217 | ### `RemoteDataSource.request(requestOptions)` 218 | 219 | The `RemoteDataSource` contains a single function, `request`, which takes a single options argument. For documentation of the options you can pass, please see the [`@cycle/http` docs](https://cycle.js.org/api/http.html). 220 | 221 | `RemoteDataSource.request()` returns a `MemoryStream` of `RemoteData` states. 222 | 223 | ### `RemoteData` 224 | 225 | `RemoteData` is a generic interface with four possible constructors: Ok, Error, Loading and NotAsked. The only state directly available is `NotAsked`, so that it can be used by users. 226 | 227 | `RemoteData` is satisfied by two methods: 228 | 229 | #### `when(cases: Cases): U` 230 | 231 | Takes an object of cases. The following cases must be handled, with the provided signatures: 232 | 233 | ```ts 234 | interface Cases { 235 | Ok(t: T): U; 236 | Error(err: ResponseError): U; 237 | Loading(progress: number): U; 238 | NotAsked(): U; 239 | } 240 | ``` 241 | 242 | A `ResponseError` is a superagent error which has a `Response` attached. 243 | 244 | If a case is not handled, a runtime error can occur. For this reason, it's recommended to use TypeScript in strict mode. 245 | 246 | #### `rmap(f: (t: T) => U): RemoteData` 247 | 248 | `rmap` is used for applying functions to the `Ok` value. A `RemoteData` would have an `Ok` value of type `T`. Applying a function that translates from `T => U` would return a `RemoteData`. 249 | 250 | 251 | ## FAQ 252 | 253 | **Doesn't this go against Cycle's idioms? You're using a source method for write effects‽** 254 | 255 | Yes, it does conflict with Cycle's current ideology. However, HTTP is a complex problem to model in Cycle, as it is a combination of intertwined read and write effects. 256 | 257 | I think it's important to be willing to try different approaches. Perhaps we will discover subtle tradeoffs that we never would have encountered if we weren't willing to think differently. 258 | 259 | 260 | **Maybe this is okay for GET requests, but do you seriously advocate using it for POST/PUT/DELETE/etc?** 261 | 262 | I'm personally not sure. I think it depends on what feedback you need to give the user on the outcome of the request. 263 | 264 | If it turns out that this feels very wrong, I'll consider handling POST/PUT/DELETE via sinks instead, like `@cycle/http`. 265 | 266 | ## Types 267 | 268 | `cycle-remote-data` provides a few useful type definitions. 269 | 270 | ```js 271 | import {RemoteDataSource, RemoteData, RemoteResponse} from 'cycle-remote-data'; 272 | ``` 273 | 274 | `RemoteResponse` is a shorthand for `RemoteData`, which is the type of the items in the stream return by `request`. 275 | 276 | ## Acknowledgments 277 | 278 | `cycle-remote-data` is inspired by the excellent [remotedata package](http://package.elm-lang.org/packages/krisajenkins/remotedata/4.3.3) for Elm, by [krisajenkins](https://github.com/krisajenkins). 279 | 280 | 281 | ## License 282 | 283 | MIT 284 | 285 | --------------------------------------------------------------------------------