├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── example ├── .babelrc ├── ActionTypes.js ├── README.md ├── actions │ └── index.js ├── components │ ├── Repos.jsx │ ├── UserSearchInput.jsx │ └── UserSearchResults.jsx ├── configureStore.js ├── containers │ ├── Admin.jsx │ ├── App.jsx │ ├── ReposByUser.jsx │ └── UserSearch.jsx ├── cycle │ ├── index.js │ └── test │ │ ├── helpers.js │ │ └── test.js ├── index.html ├── index.js ├── package.json ├── reducers │ ├── adminAccess.js │ ├── index.js │ ├── reposByUser.js │ ├── searchInFlight.js │ └── userResults.js └── webpack.config.js ├── index.d.ts ├── logo.png ├── package.json ├── src ├── combineCycles.js ├── createCycleMiddleware.js └── index.js └── test └── createCycleMiddleware.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' 4 | script: npm install && npm test && cd example && npm install && npm test 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Luca Matteis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux-cycles 2 | 3 |
4 | Redux + Cycle.js = Love 5 |
6 | 7 | Handle redux async actions using [Cycle.js](https://cycle.js.org/). 8 | 9 | [![Build Status](https://travis-ci.org/cyclejs-community/redux-cycles.svg?branch=master)](https://travis-ci.org/cyclejs-community/redux-cycles) 10 | 11 | ### Table of Contents 12 | 13 | * [Install](#install) 14 | * [Example](#example) 15 | * [Why?](#why) 16 | * [I already know Redux-thunk](#i-already-know-redux-thunk) 17 | * [I already know Redux-saga](#i-already-know-redux-saga) 18 | * [I already know Redux-observable](#i-already-know-redux-observable) 19 | * [Do I have to buy all-in?](#do-i-have-to-buy-all-in) 20 | * [What's this Cycle thing anyway?](#whats-this-cycle-thing-anyway) 21 | * [What does this look like?](#what-does-this-look-like) 22 | * [Drivers](#drivers) 23 | * [Utils](#utils) 24 | * [`combineCycles`](#combinecycles) 25 | * [Testing](#testing) 26 | * [Why not just use Cycle.js?](#why-not-just-use-cyclejs) 27 | * What's the difference between "adding Redux to Cycle.js" and "adding Cycle.js to Redux"? 28 | 29 | ## Install 30 | 31 | `npm install --save redux-cycles` 32 | 33 | Then use `createCycleMiddleware()` which returns the redux middleware function with two driver factories attached: `makeActionDriver()` and `makeStateDriver()`. Use them when you call the Cycle `run` function (can be installed via `npm install --save @cycle/run`). 34 | 35 | ```js 36 | import { run } from '@cycle/run'; 37 | import { createCycleMiddleware } from 'redux-cycles'; 38 | 39 | function main(sources) { 40 | const pong$ = sources.ACTION 41 | .filter(action => action.type === 'PING') 42 | .mapTo({ type: 'PONG' }); 43 | 44 | return { 45 | ACTION: pong$ 46 | } 47 | } 48 | 49 | const cycleMiddleware = createCycleMiddleware(); 50 | const { makeActionDriver } = cycleMiddleware; 51 | 52 | const store = createStore( 53 | rootReducer, 54 | applyMiddleware(cycleMiddleware) 55 | ); 56 | 57 | run(main, { 58 | ACTION: makeActionDriver() 59 | }) 60 | ``` 61 | 62 | By default `@cycle/run` uses `xstream`. If you want to use another streaming library simply import it and use its `run` method instead. 63 | 64 | For RxJS: 65 | 66 | ```js 67 | import { run } from '@cycle/rxjs-run'; 68 | ``` 69 | 70 | For Most.js: 71 | 72 | ```js 73 | import { run } from '@cycle/most-run'; 74 | ``` 75 | 76 | 77 | ## Example 78 | 79 | Try out this [JS Bin](https://jsbin.com/bomugapuxi/2/edit?js,output). 80 | 81 | See a real world example: [cycle autocomplete](https://github.com/cyclejs-community/redux-cycles/blob/master/example/cycle/index.js). 82 | 83 | ## Why? 84 | 85 | There already are several side-effects solutions in the Redux ecosystem: 86 | 87 | * [redux-thunk](https://github.com/gaearon/redux-thunk) 88 | * [redux-saga](https://github.com/redux-saga/redux-saga) 89 | * [redux-ship](https://clarus.github.io/redux-ship/) 90 | * [redux-observable](http://redux-observable.js.org) 91 | 92 | Why create yet another one? 93 | 94 | The intention with redux-cycles was not to worsen the "JavaScript fatigue". 95 | Rather it provides a solution that solves several problems attributable to the currently available libraries. 96 | 97 | * **Respond to actions as they happen, from the side.** 98 | 99 | Redux-thunk forces you to put your logic directly into the action creator. 100 | This means that all the logic caused by a particular action is located in one place... which doesn't do the readability a favor. 101 | It also means cross-cutting concerns like analytics get spread out across many files and functions. 102 | 103 | Redux-cycles, instead, joins redux-saga and redux-observable in allowing you to respond to any action without embedding all your logic inside an action creator. 104 | 105 | * **Declarative side-effects.** 106 | 107 | For several reasons: code clarity and testability. 108 | 109 | With redux-thunk and redux-observable you just smash everything together. 110 | 111 | Redux-saga does make testing easier to an extent, but side-effects are still ad-hoc. 112 | 113 | Redux-cycles, powered by Cycle.js, introduces an abstraction for reaching into the real world in an explicit manner. 114 | 115 | * **Statically typable.** 116 | 117 | Because static typing helps you catch several types of mistakes early on. 118 | It also allows you to model data and relationships in your program upfront. 119 | 120 | Redux-saga falls short in the typing department... but it's not its fault entirely. 121 | The JS generator syntax is tricky to type, and even when you try to, you'll find that typing anything inside the `catch`/`finally` blocks will lead to unexpected behavior. 122 | 123 | Observables, on the other hand, are easier to type. 124 | 125 | ### I already know Redux-thunk 126 | 127 | If you already know Redux-thunk, but find it limiting or clunky, Redux-cycles can help you to: 128 | 129 | * Move business logic out of action creators, leaving them pure and simple. 130 | 131 | You don't necessarily need Redux-cycles if your goal is only that. 132 | You might find Redux-saga to be easier to switch to. 133 | 134 | ### I already know Redux-saga 135 | 136 | Redux-cycles can help you to: 137 | 138 | * Handle your side-effects declaratively. 139 | 140 | Side-effect handling in Redux-saga makes testing easier compared to thunks, but you're still ultimately doing glorified function calls. 141 | The Cycle.js architecture pushes side-effect handling further to the edges of your application, leaving your "cycles" operate on pure streams. 142 | 143 | * Type your business logic. 144 | 145 | Most of your business logic lives in sagas... and they are hard/impossible to statically type. 146 | Have you had silly bugs in your sagas that Flow could have caught? 147 | I sure had. 148 | 149 | ### I already know Redux-observable 150 | 151 | Redux-cycles appears to be similar to Redux-observable... which it is, due to embracing observables. 152 | So why might you want to try Redux-cycles? 153 | 154 | In a word: easier side-effect handling. 155 | With Redux-observable your side-effectful code is scattered through all your epics, *directly*. 156 | 157 | It's hard to test. 158 | The code is less legible. 159 | 160 | ### Do I have to buy all-in? 161 | 162 | Should you go ahead and rewrite the entirety of your application in Redux-cycles to take advantage of it? 163 | 164 | **Not at all.** 165 | 166 | It's not the best strategy really. 167 | What you might want to do instead is to identify a small distinct "category" of side-effectful logic in your current side-effect model, and try transitioning only this part to use Redux-cycles, and see how you feel. 168 | 169 | A great example of a small category like that could be: 170 | 171 | * local storage calls 172 | * payments API 173 | 174 | The domain API layer often is not the easiest one to switch, so if you're thinking that... think of something smaller :) 175 | 176 | **Redux-saga** can still be valuable, even if using Redux-cycles. 177 | Certain sagas read crystal clear; sagas that orchestrate user flow. 178 | 179 | Like onboarding maybe: after the user signs up, and adds two todos, show a "keep going!" popup. 180 | 181 | This kind of logic fits the imperative sagas model *perfectly*, and it will likely look more cryptic if you try to redo it reactively. 182 | 183 | Life's not all-or-nothing, you can definitely use Redux-cycles and Redux-saga side-by-side. 184 | 185 | ## What's this Cycle thing anyway? 186 | 187 | [Cycle.js](https://cycle.js.org) is an interesting and unusual way of representing real-world programs. 188 | 189 | The program is represented as a pure function, which takes in some *sources* about events in the real world (think a stream of Redux actions), does something with it, and returns *sinks*, aka streams with commands to be performed. 190 | 191 |
192 |
stream
193 |
is like an asynchronous, always-changing array of values
194 |
source
195 |
is a stream of real-world events as they happen
196 |
sink
197 |
is a stream of commands to be performed
198 |
a cycle (not to be confused with Cycle.js the library)
199 |
is a building block of Cycle.js, a function which takes sources (at least ACTION and STATE), and returns sinks
200 |
201 | 202 | Redux-cycles provides an `ACTION` source, which is a stream of Redux actions, and listens to the `ACTION` sink. 203 | 204 | ```javascript 205 | function main(sources) { 206 | const pong$ = sources.ACTION 207 | .filter(action => action.type === 'PING') 208 | .mapTo({ type: 'PONG' }); 209 | 210 | return { 211 | ACTION: pong$ 212 | } 213 | } 214 | ``` 215 | 216 | Custom side-effects are handled similarly — by providing a different source and listening to a different sink. 217 | An example with HTTP requests will be shown later in this readme. 218 | 219 | Aside: while the Cycle.js website aims to sell you on Cycle.js for everything—including the view layer—you do *not* have to use Cycle like that. 220 | With Redux-cycles, you are effectively using Cycle only for side-effect management, leaving the view to React, and the state to Redux. 221 | 222 | ## What does this look like? 223 | 224 | Here's how Async is done using [redux-observable](https://github.com/redux-observable/redux-observable). 225 | The problem is that we still have side-effects in our epics (`ajax.getJSON`). 226 | This means that we're still writing imperative code: 227 | 228 | ```js 229 | const fetchUserEpic = action$ => 230 | action$.ofType(FETCH_USER) 231 | .mergeMap(action => 232 | ajax.getJSON(`https://api.github.com/users/${action.payload}`) 233 | .map(fetchUserFulfilled) 234 | ); 235 | ``` 236 | 237 | With Cycle.js we can push them even further outside our app using drivers, allowing us to write entirely declarative code: 238 | 239 | ```js 240 | function main(sources) { 241 | const request$ = sources.ACTION 242 | .filter(action => action.type === FETCH_USER) 243 | .map(action => ({ 244 | url: `https://api.github.com/users/${action.payload}`, 245 | category: 'users', 246 | })); 247 | 248 | const action$ = sources.HTTP 249 | .select('users') 250 | .flatten() 251 | .map(fetchUserFulfilled); 252 | 253 | return { 254 | ACTION: action$, 255 | HTTP: request$ 256 | }; 257 | } 258 | ``` 259 | 260 | This middleware intercepts Redux actions and allows us to handle them using Cycle.js in a pure data-flow manner, without side effects. It was heavily inspired by [redux-observable](https://github.com/redux-observable/redux-observable), but instead of `epics` there's an `ACTION` driver observable with the same actions-in, actions-out concept. The main difference is that you can handle them inside the Cycle.js loop and therefore take advantage of the power of Cycle.js functional reactive programming paradigms. 261 | 262 | ## Drivers 263 | 264 | Redux-cycles ships with two drivers: 265 | 266 | * `makeActionDriver()`, which is a read-write driver, allowing to react to actions that have just happened, as well as to dispatch new actions. 267 | * `makeStateDriver()`, which is a read-only driver that streams the current redux state. It's a reactive counterpart of the `yield select(state => state)` effect in Redux-saga. 268 | 269 | ```javascript 270 | import sampleCombine from 'xstream/extra/sampleCombine' 271 | 272 | function main(sources) { 273 | const state$ = sources.STATE; 274 | const isOdd$ = state$.map(state => state.counter % 2 === 0); 275 | const increment$ = sources.ACTION 276 | .filter(action => action.type === INCREMENT_IF_ODD) 277 | .compose(sampleCombine(isOdd$)) 278 | .map(([ action, isOdd ]) => isOdd ? increment() : null) 279 | .filter(action => action); 280 | 281 | return { 282 | ACTION: increment$ 283 | }; 284 | } 285 | ``` 286 | 287 | Here's an example on [how the STATE driver works](https://jsbin.com/rohomaxuma/2/edit?js,output). 288 | 289 | ## Utils 290 | 291 | ### `combineCycles` 292 | 293 | Redux-cycles ships with a `combineCycles` util. As the name suggests, it allows you to take multiple cycle apps (main functions) and combine them into a single one. 294 | 295 | **Example**: 296 | 297 | ```javascript 298 | import { combineCycles } from 'redux-cycles'; 299 | 300 | // import all your cycle apps (main functions) you intend to use with the middleware: 301 | import fetchReposByUser from './fetchReposByUser'; 302 | import searchUsers from './searchUsers'; 303 | import clearSearchResults from './clearSearchResults'; 304 | 305 | export default combineCycles( 306 | fetchReposByUser, 307 | searchUsers, 308 | clearSearchResults 309 | ); 310 | 311 | ``` 312 | 313 | You can see it used in the provided [example](https://github.com/cyclejs-community/redux-cycles/blob/master/example/cycle/index.js). 314 | 315 | ## Testing 316 | 317 | Since your main Cycle functions are pure dataflow, you can test them quite easily by giving streams as input and expecting specific streams as outputs. Checkout [these example tests](https://github.com/cyclejs-community/redux-cycles/blob/master/example/cycle/test/test.js). Also checkout the [cyclejs/time](https://github.com/cyclejs/time) project, which should work perfectly with redux-cycles. 318 | 319 | ## Why not just use Cycle.js? 320 | 321 | Mainly because Cycle.js does not say anything about how to handle state, so Redux, which has specific rules for state management, is something that can be used along with Cycle.js. This middleware allows you to continue using your Redux/React stack, while allowing you to get your hands wet with FRP and Cycle.js. 322 | 323 | ### What's the difference between "adding Redux to Cycle.js" and "adding Cycle.js to Redux"? 324 | 325 | This middleware doesn't mix Cycle.js with Redux/React at all (like other cycle-redux middlewares do). It behaves completely separately and it's meant to (i) intercept actions, (ii) react upon them functionally and purely, and (iii) dispatch new actions. So you can build your whole app without this middleware, then once you're ready to do async stuff, you can plug it in to handle your async stuff with Cycle. 326 | 327 | You should think of this middleware as a different option to handle side-effects in React/Redux apps. Currently there's redux-observable and redux-saga (which uses generators). However, they're both imperative and non-reactive ways of doing async. This middleware is a way of handling your side effects in a pure and reactive way using Cycle.js. 328 | -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015" ] 3 | } 4 | -------------------------------------------------------------------------------- /example/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export const SEARCHED_USERS = 'SEARCHED_USERS'; 2 | export const RECEIVED_USERS = 'RECEIVED_USERS'; 3 | export const CLEARED_SEARCH_RESULTS = 'CLEARED_SEARCH_RESULTS'; 4 | 5 | export const REQUESTED_USER_REPOS = 'REQUESTED_USER_REPOS'; 6 | export const RECEIVED_USER_REPOS = 'RECEIVED_USER_REPOS'; 7 | 8 | export const CHECKED_ADMIN_ACCESS = 'CHECKED_ADMIN_ACCESS'; 9 | export const ACCESS_DENIED = 'ACCESS_DENIED'; 10 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | npm install 3 | npm run serve 4 | ``` 5 | -------------------------------------------------------------------------------- /example/actions/index.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../ActionTypes'; 2 | 3 | export function searchUsers(query) { 4 | return { 5 | type: ActionTypes.SEARCHED_USERS, 6 | payload: { 7 | query 8 | } 9 | }; 10 | } 11 | 12 | export function receiveUsers(users) { 13 | return { 14 | type: ActionTypes.RECEIVED_USERS, 15 | payload: { 16 | users 17 | } 18 | }; 19 | } 20 | 21 | export function clearSearchResults() { 22 | return { 23 | type: ActionTypes.CLEARED_SEARCH_RESULTS 24 | }; 25 | } 26 | 27 | export function requestReposByUser(user) { 28 | return { 29 | type: ActionTypes.REQUESTED_USER_REPOS, 30 | payload: { 31 | user 32 | } 33 | }; 34 | } 35 | 36 | export function receiveUserRepos(user, repos) { 37 | return { 38 | type: ActionTypes.RECEIVED_USER_REPOS, 39 | payload: { 40 | user, 41 | repos 42 | } 43 | }; 44 | } 45 | 46 | export function checkAdminAccess() { 47 | return { 48 | type: ActionTypes.CHECKED_ADMIN_ACCESS 49 | }; 50 | } 51 | 52 | export function accessDenied() { 53 | return { 54 | type: ActionTypes.ACCESS_DENIED 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /example/components/Repos.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Repos({ repos, user }) { 4 | return ( 5 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /example/components/UserSearchInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function UserSearchInput({ value, defaultValue, onChange }) { 4 | return ( 5 | onChange(evt.target.value)} 10 | /> 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /example/components/UserSearchResults.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | export default function UserSearchResults({ 5 | results, 6 | loading 7 | }) { 8 | return ( 9 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /example/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import { browserHistory } from 'react-router'; 3 | import { routerMiddleware } from 'react-router-redux'; 4 | import rootReducer from './reducers'; 5 | import main from './cycle'; 6 | import { createCycleMiddleware } from 'redux-cycles'; 7 | import {run} from '@cycle/run'; 8 | import {makeHTTPDriver} from '@cycle/http'; 9 | import {timeDriver} from '@cycle/time'; 10 | 11 | export default function configureStore() { 12 | const cycleMiddleware = createCycleMiddleware(); 13 | const { makeActionDriver, makeStateDriver } = cycleMiddleware; 14 | 15 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 16 | const store = createStore( 17 | rootReducer, 18 | composeEnhancers( 19 | applyMiddleware( 20 | cycleMiddleware, 21 | routerMiddleware(browserHistory) 22 | ) 23 | ) 24 | ); 25 | 26 | run(main, { 27 | ACTION: makeActionDriver(), 28 | STATE: makeStateDriver(), 29 | Time: timeDriver, 30 | HTTP: makeHTTPDriver(), 31 | }) 32 | 33 | return store; 34 | } 35 | -------------------------------------------------------------------------------- /example/containers/Admin.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { checkAdminAccess } from '../actions'; 4 | 5 | class Admin extends React.Component { 6 | componentDidMount() { 7 | this.props.checkAdminAccess(); 8 | } 9 | 10 | render() { 11 | if (!this.props.adminAccess) { 12 | return ( 13 |

Checking access...

14 | ); 15 | } 16 | if (this.props.adminAccess === 'GRANTED') { 17 | return ( 18 |

Access granted

19 | ); 20 | } 21 | return ( 22 |

23 | Access denied. Redirecting back home. 24 |

25 | ); 26 | } 27 | } 28 | 29 | export default connect( 30 | ({ adminAccess }) => ({ adminAccess }), 31 | { checkAdminAccess } 32 | )(Admin); 33 | -------------------------------------------------------------------------------- /example/containers/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function App({ children }) { 4 | return ( 5 |
6 | {children} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /example/containers/ReposByUser.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Repos from '../components/Repos'; 4 | import { requestReposByUser } from '../actions'; 5 | 6 | class ReposByUser extends React.Component { 7 | componentDidMount() { 8 | this.props.requestReposByUser(this.props.params.user); 9 | } 10 | 11 | componentWillReceiveProps(nextProps) { 12 | const { user } = this.props.params; 13 | if (user !== nextProps.params.user) { 14 | this.props.requestReposByUser(user); 15 | } 16 | } 17 | 18 | render() { 19 | const { 20 | reposByUser, 21 | user 22 | } = this.props; 23 | if (!reposByUser[user]) { 24 | return ( 25 |

Loading

26 | ); 27 | } 28 | return ( 29 | 33 | ); 34 | } 35 | } 36 | 37 | export default connect( 38 | ({ reposByUser }, ownProps) => ({ 39 | reposByUser, 40 | user: ownProps.params.user 41 | }), 42 | { requestReposByUser } 43 | )(ReposByUser); 44 | -------------------------------------------------------------------------------- /example/containers/UserSearch.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router'; 4 | import UserSearchInput from '../components/UserSearchInput'; 5 | import UserSearchResults from '../components/UserSearchResults'; 6 | import { searchUsers } from '../actions'; 7 | 8 | class UserSearch extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.handleUserSearch = this.handleUserSearch.bind(this); 12 | } 13 | 14 | componentDidMount() { 15 | this.handleUserSearch(this.props.query); 16 | } 17 | 18 | componentWillReceiveProps(nextProps) { 19 | if (this.props.query !== nextProps.query) { 20 | this.handleUserSearch(nextProps.query); 21 | } 22 | } 23 | 24 | handleUserSearch(query) { 25 | this.props.searchUsers(query); 26 | } 27 | 28 | render() { 29 | const { 30 | query, 31 | results, 32 | searchInFlight 33 | } = this.props; 34 | return ( 35 |
36 | 42 | Admin Panel 43 | 44 | 48 | 52 |
53 | ); 54 | } 55 | } 56 | 57 | export default connect( 58 | ({ routing, userResults, searchInFlight }) => ({ 59 | query: routing.locationBeforeTransitions.query.q, 60 | results: userResults, 61 | searchInFlight 62 | }), 63 | { searchUsers } 64 | )(UserSearch); 65 | -------------------------------------------------------------------------------- /example/cycle/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../actions'; 2 | import * as ActionTypes from '../ActionTypes'; 3 | 4 | import { combineCycles } from 'redux-cycles'; 5 | import xs from 'xstream'; 6 | 7 | export function fetchReposByUser(sources) { 8 | const user$ = sources.ACTION 9 | .filter(action => action.type === ActionTypes.REQUESTED_USER_REPOS) 10 | .map(action => action.payload.user); 11 | 12 | const request$ = user$ 13 | .map(user => ({ 14 | url: `https://api.github.com/users/${user}/repos`, 15 | category: 'users' 16 | })); 17 | 18 | const response$ = sources.HTTP 19 | .select('users') 20 | .flatten(); 21 | 22 | const action$ = xs.combine(response$, user$) 23 | .map(arr => actions.receiveUserRepos(arr[1], arr[0].body)); 24 | 25 | return { 26 | ACTION: action$, 27 | HTTP: request$ 28 | } 29 | } 30 | 31 | export function searchUsers(sources) { 32 | const searchQuery$ = sources.ACTION 33 | .filter(action => action.type === ActionTypes.SEARCHED_USERS) 34 | .map(action => action.payload.query) 35 | .filter(q => !!q) 36 | .map(q => 37 | sources.Time.periodic(800) 38 | .take(1) 39 | .mapTo(q) 40 | .endWhen( 41 | sources.ACTION.filter(action => 42 | action.type === ActionTypes.CLEARED_SEARCH_RESULTS) 43 | ) 44 | ) 45 | .flatten() 46 | 47 | const searchQueryRequest$ = searchQuery$ 48 | .map(q => ({ 49 | url: `https://api.github.com/search/users?q=${q}`, 50 | category: 'query' 51 | })) 52 | 53 | const searchQueryResponse$ = sources.HTTP 54 | .select('query') 55 | .flatten() 56 | .map(res => res.body.items) 57 | .map(actions.receiveUsers) 58 | 59 | return { 60 | ACTION: searchQueryResponse$, 61 | HTTP: searchQueryRequest$ 62 | } 63 | } 64 | 65 | function clearSearchResults(sources) { 66 | const clear$ = sources.ACTION 67 | .filter(action => action.type === ActionTypes.SEARCHED_USERS) 68 | .filter(action => !!!action.payload.query) 69 | .map(actions.clearSearchResults); 70 | 71 | return { 72 | ACTION: clear$ 73 | } 74 | } 75 | 76 | export default combineCycles(fetchReposByUser, searchUsers, clearSearchResults); 77 | -------------------------------------------------------------------------------- /example/cycle/test/helpers.js: -------------------------------------------------------------------------------- 1 | import {mockTimeSource} from '@cycle/time'; 2 | 3 | export function assertSourcesSinks(sources, sinks, main, done, timeOpts = {}) { 4 | const Time = mockTimeSource(timeOpts); 5 | const _sources = Object.keys(sources) 6 | .reduce((_sources, sourceKey) => { 7 | const sourceObj = sources[sourceKey]; 8 | const diagram = Object.keys(sourceObj)[0]; 9 | const sourceOpts = sourceObj[diagram]; 10 | 11 | let obj = {}; 12 | let firstKey = Object.keys(sourceOpts)[0]; 13 | if (typeof sourceOpts[firstKey] === 'function') { 14 | obj = { 15 | [sourceKey]: { 16 | [firstKey]: () => Time.diagram(diagram, sourceOpts[firstKey]()) 17 | } 18 | } 19 | } else { 20 | obj = { 21 | [sourceKey]: Time.diagram(diagram, sourceOpts) 22 | } 23 | } 24 | 25 | return Object.assign(_sources, obj); 26 | }, {}) 27 | 28 | const _sinks = Object.keys(sinks) 29 | .reduce((_sinks, sinkKey) => { 30 | const sinkObj = sinks[sinkKey]; 31 | const diagram = Object.keys(sinkObj)[0]; 32 | const sinkOpts = sinkObj[diagram]; 33 | 34 | return Object.assign(_sinks, { [sinkKey]: Time.diagram(diagram, sinkOpts) }); 35 | }, {}); 36 | 37 | // always pass Time as a source 38 | _sources.Time = Time; 39 | 40 | const _main = main(_sources); 41 | 42 | Object.keys(sinks) 43 | .map(sinkKey => Time.assertEqual(_main[sinkKey], _sinks[sinkKey])); 44 | 45 | Time.run(err => { 46 | expect(err).toBeFalsy(); 47 | done(); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /example/cycle/test/test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import xs from 'xstream'; 3 | 4 | import { assertSourcesSinks } from './helpers'; 5 | import * as ActionTypes from '../../ActionTypes'; 6 | import * as actions from '../../actions'; 7 | 8 | import { fetchReposByUser, searchUsers } from '../'; 9 | 10 | describe('Cycles', function() { 11 | describe('fetchReposByUser', function() { 12 | it('should emit HTTP requests given ACTIONs', function(done) { 13 | const user1 = 'lmatteis'; 14 | const user2 = 'luca'; 15 | 16 | const actionSource = { 17 | a: actions.requestReposByUser(user1), 18 | b: actions.requestReposByUser(user2) 19 | }; 20 | 21 | const httpSource = { 22 | select: () => null 23 | } 24 | 25 | const httpSink = { 26 | x: { 27 | url: `https://api.github.com/users/${user1}/repos`, 28 | category: 'users' 29 | }, 30 | y: { 31 | url: `https://api.github.com/users/${user2}/repos`, 32 | category: 'users' 33 | } 34 | }; 35 | 36 | // Asserts that the sources, trigger the provided sinks, 37 | // when executing the fetchReposByUser function 38 | assertSourcesSinks({ 39 | ACTION: { 'ab|': actionSource }, 40 | HTTP: { '--|': httpSource } 41 | }, { 42 | HTTP: { 'xy|': httpSink } 43 | }, fetchReposByUser, done); 44 | 45 | }); 46 | 47 | it('should emit ACTION given HTTP response', function(done) { 48 | const user1 = 'lmatteis'; 49 | const user2 = 'luca'; 50 | 51 | const response = { body: { foo: 'bar' } }; 52 | 53 | const actionSource = { 54 | a: actions.requestReposByUser(user1) 55 | }; 56 | 57 | const httpSource = { 58 | select: () => ({ 59 | r: xs.of(response) 60 | }) 61 | }; 62 | 63 | const actionSink = { 64 | a: actions.receiveUserRepos(user1, response.body) 65 | }; 66 | 67 | assertSourcesSinks({ 68 | ACTION: { 'a|': actionSource }, 69 | HTTP: { 'r|': httpSource } 70 | }, { 71 | ACTION: { 'a|': actionSink } 72 | }, fetchReposByUser, done); 73 | 74 | }); 75 | }); 76 | 77 | describe('searchUsers', () => { 78 | it('should emit HTTP requests given many debounced ACTIONs, and should emit ACTION given HTTP response', (done) => { 79 | const actionSource = { 80 | a: actions.searchUsers('l'), 81 | b: actions.searchUsers('lu'), 82 | c: actions.searchUsers('luc') 83 | }; 84 | const httpSource = { 85 | select: () => ({ 86 | r: xs.of({ body: { items: ['foo'] } }) 87 | }) 88 | } 89 | const httpSink = { 90 | a: { 91 | url: `https://api.github.com/search/users?q=luc`, 92 | category: 'query' 93 | } 94 | } 95 | const actionSink = { 96 | r: actions.receiveUsers(['foo']), 97 | } 98 | 99 | assertSourcesSinks({ 100 | ACTION: { '-a-b-c----|': actionSource }, 101 | HTTP: { '---r------|': httpSource }, 102 | }, { 103 | HTTP: { '---------a|': httpSink }, 104 | ACTION: { '---r------|': actionSink }, 105 | }, searchUsers, done, { interval: 200 }); 106 | }) 107 | }) 108 | }); 109 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Navigation example 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { Router, Route, browserHistory, IndexRoute } from 'react-router'; 5 | import { syncHistoryWithStore } from 'react-router-redux'; 6 | import configureStore from './configureStore'; 7 | import App from './containers/App'; 8 | import UserSearch from './containers/UserSearch'; 9 | import ReposByUser from './containers/ReposByUser'; 10 | import Admin from './containers/Admin'; 11 | 12 | const store = configureStore(); 13 | const history = syncHistoryWithStore( 14 | browserHistory, 15 | store 16 | ); 17 | 18 | ReactDOM.render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | , 28 | document.querySelector('.app') 29 | ); 30 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-cycles-example", 3 | "version": "0.0.1", 4 | "description": "Bring functional reactive programming to Redux using Cycle.js", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npm run build && jest", 8 | "serve": "webpack-dev-server --inline --history-api-fallback", 9 | "build": "webpack" 10 | }, 11 | "repository": "https://github.com/cyclejs-community/redux-cycles", 12 | "author": "Luca Matteis", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@cycle/http": "^12.0.0", 16 | "@cycle/run": "^1.0.0", 17 | "history": "^2.1.2", 18 | "react": "^15.3.0", 19 | "react-dom": "^15.3.0", 20 | "react-redux": "^4.4.5", 21 | "react-router": "^2.6.1", 22 | "react-router-redux": "^4.0.5", 23 | "redux": "^3.5.2", 24 | "redux-cycles": "file:../", 25 | "xstream": "^10" 26 | }, 27 | "devDependencies": { 28 | "@cycle/time": "^0.7.1", 29 | "babel-core": "^6.11.4", 30 | "babel-loader": "^6.2.4", 31 | "babel-preset-es2015": "^6.9.0", 32 | "babel-preset-react": "^6.11.1", 33 | "jest": "^18.1.0", 34 | "webpack": "^1.13.1", 35 | "webpack-dev-server": "^1.14.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /example/reducers/adminAccess.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../ActionTypes'; 2 | 3 | const DENIED = 'DENIED'; 4 | 5 | export default function adminAccess(state = null, action) { 6 | switch (action.type) { 7 | case ActionTypes.ACCESS_DENIED: 8 | return DENIED; 9 | default: 10 | return state; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /example/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { routerReducer } from 'react-router-redux'; 3 | import userResults from './userResults'; 4 | import searchInFlight from './searchInFlight'; 5 | import reposByUser from './reposByUser'; 6 | import adminAccess from './adminAccess'; 7 | 8 | export default combineReducers({ 9 | userResults, 10 | searchInFlight, 11 | reposByUser, 12 | adminAccess, 13 | routing: routerReducer 14 | }); 15 | -------------------------------------------------------------------------------- /example/reducers/reposByUser.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../ActionTypes'; 2 | 3 | export default function reposByUser(state = {}, action) { 4 | switch (action.type) { 5 | case ActionTypes.REQUESTED_USER_REPOS: 6 | return Object.assign({}, state, { 7 | [action.payload.user]: undefined 8 | }); 9 | case ActionTypes.RECEIVED_USER_REPOS: 10 | return Object.assign({}, state, { 11 | [action.payload.user]: action.payload.repos 12 | }); 13 | default: 14 | return state; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /example/reducers/searchInFlight.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../ActionTypes'; 2 | 3 | export default function searchInFlight(state = false, action) { 4 | switch (action.type) { 5 | case ActionTypes.SEARCHED_USERS: 6 | return true; 7 | case ActionTypes.RECEIVED_USERS: 8 | case ActionTypes.CLEARED_SEARCH_RESULTS: 9 | return false; 10 | default: 11 | return state; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/reducers/userResults.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../ActionTypes'; 2 | 3 | const initialState = []; 4 | export default function userResults(state = initialState, action) { 5 | switch (action.type) { 6 | case ActionTypes.RECEIVED_USERS: 7 | return action.payload.users; 8 | case ActionTypes.CLEARED_SEARCH_RESULTS: 9 | return initialState; 10 | default: 11 | return state; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | entry: './index.js', 5 | output: { 6 | path: path.join(__dirname, 'dist'), 7 | filename: 'bundle.js', 8 | publicPath: '/static/' 9 | }, 10 | module: { 11 | loaders: [ 12 | { 13 | test: /\.(js|jsx)$/, 14 | exclude: /node_modules/, 15 | loader: 'babel', 16 | query: { 17 | presets: ['es2015', 'react'], 18 | babelrc: false 19 | } 20 | } 21 | ] 22 | }, 23 | resolve: { 24 | extensions: ['', '.js', '.jsx'], 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, Action } from 'redux' 2 | import { Stream } from 'xstream' 3 | import { Sources, Sinks } from '@cycle/run' 4 | 5 | type CycleMiddleware = Middleware & { 6 | makeActionDriver(): (outgoing$: Stream) => T 7 | makeStateDriver(): () => T 8 | } 9 | 10 | type Main = (sources: So) => Si 11 | 12 | export function createCycleMiddleware(): CycleMiddleware 13 | 14 | export function combineCycles(main: Main[]): Main 15 | export function combineCycles(main1: Main, main2: Main): Main 16 | export function combineCycles(main1: Main, main2: Main, main3: Main): Main 17 | export function combineCycles(main1: Main, main2: Main, main3: Main, main4: Main): Main 18 | export function combineCycles(main1: Main, main2: Main, main3: Main, main4: Main, main5: Main): Main 19 | export function combineCycles(main1: Main, main2: Main, main3: Main, main4: Main, main5: Main, main6: Main): Main 20 | export function combineCycles(main1: Main, main2: Main, main3: Main, main4: Main, main5: Main, main6: Main, main7: Main): Main 21 | export function combineCycles(main1: Main, main2: Main, main3: Main, main4: Main, main5: Main, main6: Main, main7: Main, main8: Main): Main 22 | export function combineCycles(main1: Main, main2: Main, main3: Main, main4: Main, main5: Main, main6: Main, main7: Main, main8: Main, main9: Main): Main -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyclejs-community/redux-cycles/dd953bc8416eec9637da586a9acaeab473de228b/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-cycles", 3 | "version": "0.4.1", 4 | "description": "Bring functional reactive programming to Redux using Cycle.js", 5 | "typings": "index.d.ts", 6 | "main": "dist", 7 | "files": [ 8 | "dist", 9 | "index.d.ts" 10 | ], 11 | "repository": "https://github.com/cyclejs-community/redux-cycles", 12 | "contributors": [ 13 | { 14 | "name": "Luca Matteis" 15 | }, 16 | { 17 | "name": "Nick Balestra" 18 | }, 19 | { 20 | "name": "Gosha Arinich" 21 | } 22 | ], 23 | "scripts": { 24 | "eslint": "eslint --fix src/ test/", 25 | "test": "npm run build && npm run eslint && jest", 26 | "build": "babel src --out-dir dist", 27 | "prepublish": "npm run build" 28 | }, 29 | "jest": { 30 | "testPathIgnorePatterns": [ 31 | "/example" 32 | ] 33 | }, 34 | "license": "MIT", 35 | "babel": { 36 | "presets": [ 37 | "es2015" 38 | ] 39 | }, 40 | "eslintConfig": { 41 | "env": { 42 | "browser": true, 43 | "es6": true, 44 | "node": true 45 | }, 46 | "extends": "eslint:recommended", 47 | "parserOptions": { 48 | "sourceType": "module" 49 | }, 50 | "rules": { 51 | "indent": [ 52 | "error", 53 | 2 54 | ], 55 | "linebreak-style": [ 56 | "error", 57 | "unix" 58 | ], 59 | "quotes": [ 60 | "error", 61 | "single" 62 | ], 63 | "semi": [ 64 | "error", 65 | "never" 66 | ] 67 | } 68 | }, 69 | "dependencies": { 70 | "@cycle/run": "*" 71 | }, 72 | "peerDependencies": { 73 | "xstream": "*" 74 | }, 75 | "devDependencies": { 76 | "@cycle/rxjs-run": "^4.1.0", 77 | "rxjs": "^5.2.0", 78 | "babel-cli": "^6.18.0", 79 | "babel-jest": "^18.0.0", 80 | "babel-preset-es2015": "^6.9.0", 81 | "eslint": "^3.15.0", 82 | "jest": "^18.1.0", 83 | "redux": "^3.6.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/combineCycles.js: -------------------------------------------------------------------------------- 1 | import xs from 'xstream' 2 | 3 | export default function combineCycles(...mains) { 4 | return sources => { 5 | const sinks = mains.map(main => main(sources)) 6 | 7 | const drivers = Object.keys( 8 | sinks.reduce((drivers, sink) => Object.assign(drivers, sink), {}) 9 | ) 10 | 11 | const combinedSinks = drivers 12 | .reduce((combinedSinks, driver) => { 13 | const driverSinks = sinks 14 | .filter(sink => sink[driver]) 15 | .map(sink => xs.from(sink[driver])) 16 | 17 | combinedSinks[driver] = xs.merge(...driverSinks) 18 | return combinedSinks 19 | }, {}) 20 | 21 | return combinedSinks 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/createCycleMiddleware.js: -------------------------------------------------------------------------------- 1 | import xs from 'xstream' 2 | import {adapt} from '@cycle/run/lib/adapt' 3 | 4 | export default function createCycleMiddleware () { 5 | let store = null 6 | let actionListener = null 7 | let stateListener = null 8 | 9 | const cycleMiddleware = _store => { 10 | store = _store 11 | return next => { 12 | return action => { 13 | let result = next(action) 14 | if (actionListener) { 15 | actionListener.next(action) 16 | } 17 | if (stateListener) { 18 | stateListener.next(store.getState()) 19 | } 20 | return result 21 | } 22 | } 23 | } 24 | 25 | cycleMiddleware.makeActionDriver = () => { 26 | return function actionDriver(outgoing$) { 27 | outgoing$.addListener({ 28 | next: outgoing => { 29 | if (store) { 30 | store.dispatch(outgoing) 31 | } 32 | }, 33 | error: () => {}, 34 | complete: () => {}, 35 | }) 36 | 37 | return adapt(xs.create({ 38 | start: listener => { 39 | actionListener = listener 40 | }, 41 | stop: () => {}, 42 | })) 43 | } 44 | } 45 | 46 | cycleMiddleware.makeStateDriver = () => { 47 | const isSame = {} 48 | return function stateDriver() { 49 | const getCurrent = store.getState 50 | return adapt(xs.create({ 51 | start: listener => { 52 | stateListener = listener 53 | }, 54 | stop: () => {}, 55 | }) 56 | .fold((prevState, currState) => { 57 | if (prevState === getCurrent) { 58 | prevState = getCurrent() 59 | } 60 | if (prevState === currState) { 61 | return isSame 62 | } 63 | return currState 64 | }, getCurrent) 65 | .map(state => state === getCurrent ? getCurrent() : state) 66 | .filter(state => state !== isSame)) 67 | } 68 | } 69 | 70 | return cycleMiddleware 71 | } 72 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createCycleMiddleware from './createCycleMiddleware' 2 | import combineCycles from './combineCycles' 3 | 4 | export { 5 | createCycleMiddleware, 6 | combineCycles 7 | } 8 | -------------------------------------------------------------------------------- /test/createCycleMiddleware.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable no-console */ 3 | import { createCycleMiddleware, combineCycles } from '../' 4 | import { createStore, applyMiddleware } from 'redux' 5 | import xs from 'xstream' 6 | import {run} from '@cycle/run' 7 | import {run as rxjsRun} from '@cycle/rxjs-run' 8 | import {Observable} from 'rxjs/Rx' 9 | import {setAdapt} from '@cycle/run/lib/adapt' 10 | jest.useFakeTimers() 11 | 12 | function initStore(main, drivers, reducer = null, r = run) { 13 | const rootReducer = reducer || ((state = [], action) => state.concat(action)) 14 | 15 | const cycleMiddleware = createCycleMiddleware() 16 | const { makeActionDriver, makeStateDriver } = cycleMiddleware 17 | const store = createStore( 18 | rootReducer, 19 | applyMiddleware(cycleMiddleware) 20 | ) 21 | 22 | r(main, { 23 | ACTION: makeActionDriver(), 24 | STATE: makeStateDriver() 25 | }) 26 | 27 | return store 28 | } 29 | 30 | describe('Redux cycle middleware xstream', () => { 31 | beforeEach(() => setAdapt(stream => stream)) 32 | 33 | it('dispatches a PING to see whether the middleware dispatches a PONG', (done) => { 34 | function main(sources) { 35 | const pong$ = sources.ACTION 36 | .filter(action => action.type === 'PING') 37 | .mapTo({ type: 'PONG' }) 38 | 39 | return { 40 | ACTION: pong$ 41 | } 42 | } 43 | 44 | const expectedActions = [ 45 | { type: '@@redux/INIT' }, 46 | { type: 'PING' }, 47 | { type: 'PONG' } 48 | ] 49 | const store = initStore(combineCycles(main), {}) 50 | 51 | store.dispatch({ type: 'PING' }) 52 | 53 | expect(store.getState()).toMatchObject(expectedActions) 54 | 55 | done() 56 | }) 57 | 58 | it('dispatches a PING to see whether the middleware dispatches a PONG after 10 seconds', (done) => { 59 | function main(sources) { 60 | const pong$ = sources.ACTION 61 | .filter(action => action.type === 'PING') 62 | .map(() => 63 | xs.periodic(10000) 64 | .take(1) 65 | .mapTo({ type: 'PONG' }) 66 | ) 67 | .flatten() 68 | 69 | return { 70 | ACTION: pong$ 71 | } 72 | } 73 | 74 | const expectedActions = [ 75 | { type: '@@redux/INIT' }, 76 | { type: 'PING' }, 77 | { type: 'PONG' } 78 | ] 79 | const store = initStore(main, {}) 80 | 81 | store.dispatch({ type: 'PING' }) 82 | 83 | expect(store.getState()).toEqual([ 84 | { type: '@@redux/INIT' }, 85 | { type: 'PING' } 86 | ]) 87 | 88 | expect(store.getState()).not.toMatchObject(expectedActions) 89 | jest.runAllTimers() 90 | expect(setInterval.mock.calls[0][1]).toBe(10000) 91 | expect(store.getState()).toMatchObject(expectedActions) 92 | done() 93 | }) 94 | 95 | it('dispatches INCREMENT_ASYNC and INCREMENT_IF_ODD actions to check whether state updates correctly', (done) => { 96 | function main(sources) { 97 | const state$ = sources.STATE 98 | const isOdd$ = state$ 99 | .map(state => state % 2 === 1) 100 | .take(1) 101 | 102 | const incrementIfOdd$ = sources.ACTION 103 | .filter(action => action.type === 'INCREMENT_IF_ODD') 104 | .map(() => 105 | isOdd$ 106 | ) 107 | .flatten() 108 | .filter(isOdd => isOdd) 109 | .mapTo({ type: 'INCREMENT' }) 110 | 111 | const increment$ = sources.ACTION 112 | .filter(action => action.type === 'INCREMENT_ASYNC') 113 | .mapTo({ type: 'INCREMENT' }) 114 | 115 | const decrement$ = sources.ACTION 116 | .filter(action => action.type === 'DECREMENT_ASYNC') 117 | .mapTo({ type: 'DECREMENT' }) 118 | 119 | const both$ = xs.merge(increment$, decrement$) 120 | 121 | return { 122 | ACTION: xs.merge(both$, incrementIfOdd$) 123 | } 124 | } 125 | 126 | const store = initStore(main, {}, (state = 0, action) => { 127 | switch (action.type) { 128 | case 'INCREMENT': 129 | return state + 1 130 | case 'DECREMENT': 131 | return state - 1 132 | default: 133 | return state 134 | } 135 | }) 136 | 137 | store.dispatch({ type: 'INCREMENT_ASYNC' }) 138 | expect(store.getState()).toBe(1) 139 | store.dispatch({ type: 'INCREMENT_ASYNC' }) 140 | expect(store.getState()).toBe(2) 141 | store.dispatch({ type: 'INCREMENT_ASYNC' }) 142 | expect(store.getState()).toBe(3) 143 | store.dispatch({ type: 'INCREMENT_IF_ODD' }) 144 | expect(store.getState()).toBe(4) 145 | store.dispatch({ type: 'INCREMENT_IF_ODD' }) 146 | expect(store.getState()).toBe(4) 147 | store.dispatch({ type: 'INCREMENT_ASYNC' }) 148 | expect(store.getState()).toBe(5) 149 | 150 | done() 151 | 152 | }) 153 | }) 154 | 155 | describe('Redux cycle middleware RxJS', () => { 156 | beforeEach(() => setAdapt(stream => Observable.from(stream))) 157 | 158 | it('uses rxjs-run with Cycle Unified', (done) => { 159 | function main(sources) { 160 | const pong$ = sources.ACTION 161 | .filter(action => action.type === 'PING') 162 | .do(_ => _) // do() only exists in RxJS 163 | .mapTo({ type: 'PONG' }) 164 | 165 | return { 166 | ACTION: pong$ 167 | } 168 | } 169 | 170 | const store = initStore(combineCycles(main), {}, null, rxjsRun) 171 | 172 | store.dispatch({ type: 'PING' }) 173 | 174 | const expectedActions = [ 175 | { type: '@@redux/INIT' }, 176 | { type: 'PING' }, 177 | { type: 'PONG' } 178 | ] 179 | expect(store.getState()).toMatchObject(expectedActions) 180 | 181 | done() 182 | 183 | }) 184 | }) 185 | --------------------------------------------------------------------------------