From 3bbfbfad5c7a269cb13a08f076f949c1865d1db2 Mon Sep 17 00:00:00 2001 From: M Pacer Date: Sun, 4 Feb 2018 03:56:32 -0800 Subject: [PATCH] Refactor redux observable example (#3495) * move imports into files using lettable operators, remove rxjs-library * refactor to be more in keeping with redux conventions from the single reducer.js, I split the functionality into actionTypes (actionTypes.js), actions (actions.js), and epics (epics.js). Most of the fetching should be done in an epic, but that requires introducing a new action and so was better in a separate commit. * switch to fetching on the front-end via an epic The fetching previously was triggered using an api call that had side effects, but was triggered from inside of an epic and was not an action. Now calls on the front-end all of the api calls are occuring via an action through fetchCharacterEpic. This does not remove the api.js file as I have not yet been able to get the epic to trigger correctly on the server-side, thus the api.fetchCharacter call is awaited in getInitialProps for initialising the state serverSide. * remove need for the serverSide api by directly handling the dispatch This still seems to be an incomplete solution to the problem as it circumvents the standard redux event flow on the serverside. However, it does obey the spirit of the redux event flow (as it passes an Observable of an action into the epic to then trigger other actions). Additionally, this removes the problem of code duplication. * update README.md and move lib/ to redux/ * Fix linting --- examples/with-redux-observable/README.md | 58 ++++++++++++++----- examples/with-redux-observable/lib/api.js | 12 ---- examples/with-redux-observable/lib/reducer.js | 51 ---------------- .../with-redux-observable/lib/rxjs-library.js | 13 ----- examples/with-redux-observable/pages/index.js | 17 +++--- .../redux/actionTypes.js | 5 ++ .../with-redux-observable/redux/actions.js | 21 +++++++ examples/with-redux-observable/redux/epics.js | 57 ++++++++++++++++++ .../{lib => redux}/index.js | 11 ++-- .../with-redux-observable/redux/reducer.js | 24 ++++++++ 10 files changed, 166 insertions(+), 103 deletions(-) delete mode 100644 examples/with-redux-observable/lib/api.js delete mode 100644 examples/with-redux-observable/lib/reducer.js delete mode 100644 examples/with-redux-observable/lib/rxjs-library.js create mode 100644 examples/with-redux-observable/redux/actionTypes.js create mode 100644 examples/with-redux-observable/redux/actions.js create mode 100644 examples/with-redux-observable/redux/epics.js rename examples/with-redux-observable/{lib => redux}/index.js (64%) create mode 100644 examples/with-redux-observable/redux/reducer.js diff --git a/examples/with-redux-observable/README.md b/examples/with-redux-observable/README.md index 9c5fe1cc..51943831 100644 --- a/examples/with-redux-observable/README.md +++ b/examples/with-redux-observable/README.md @@ -29,24 +29,56 @@ npm run dev ### The idea behind the example -Example is a page that renders information about Star-Wars characters. It fetches new character -every 3 seconds having the initial character fetched on a server. + +This example is a page that renders information about Star-Wars characters. It +fetches new character every 3 seconds having the initial character fetched on +a server. Example also uses `redux-logger` to log every action. ![demo page](demo.png) -The main problem with integrating Redux, Redux-Observable and Next.js is probably making initial requests -on a server. That's because it's not possible to wait until epics are resolved in `getInitialProps` hook. +The main problem with integrating Redux, Redux-Observable and Next.js is +probably making initial requests on a server. That's because, the +`getInitialProps` hook runs on the server-side before epics have been made available by just dispatching actions. -In order to have best of two worlds, we can extract request logic and use it separately. -That's what `lib/api.js` is for. It keeps functions that return configured Observable for ajax request. -You can notice that `fetchCharacter` method is used to get initial data in `pages/index.js` -and also in `lib/reducer.js` within an epic. +However, we can access and execute epics directly. In order to do so, we need to +pass them an Observable of an action and they will return an Observable: -Other than above, configuration is pretty the same as in -[with-redux example](https://github.com/zeit/next.js/tree/canary/examples/with-redux) -and [redux-observable docs](https://redux-observable.js.org/). There is, however one important thing -to note, that we are not using `AjaxObservable` from `rxjs` library because it doesn't work on Node. -Because of this we use a library like [universal-rx-request](https://www.npmjs.com/package/universal-rx-request). +```js +static async getInitialProps({ store, isServer }) { + const resultAction = await rootEpic( + of(actions.fetchCharacter(isServer)), + store + ).toPromise(); // we need to convert Observable to Promise + store.dispatch(resultAction)}; +``` +Note: we are not using `AjaxObservable` from the `rxjs` library; as of rxjs +v5.5.6, it will not work on both the server- and client-side. Instead we call +the default export from +[universal-rx-request](https://www.npmjs.com/package/universal-rx-request) (as +`ajax`). + +We transform the Observable we get from `ajax` into a Promise in order to await +its resolution. That resolution should be a action (since the epic returns +Observables of actions). We immediately dispatch that action to the store. + +This server-side solution allows compatibility with Next. It may not be +something you wish to emulate. In other situations, calling or awaiting epics +directly and passing their result to the store would be an anti-pattern. You +should only trigger epics by dispatching actions. This solution may not +generalise to resolving more complicated sets of actions. + +The layout of the redux related functionality is split between: + + - actions (in `redux/actions.js`) + - actionTypes (in `redux/actionTypes.js`) + - epics (in `redux/epics.js`) + - reducer (in `redux/reducer.js`) + +and organized in `redux/index.js`. + +Excepting in those manners discussed above, the configuration is similar the +configuration found in [with-redux example](https://github.com/zeit/next.js/tree/canary/examples/with-redux) +and [redux-observable docs](https://redux-observable.js.org/). diff --git a/examples/with-redux-observable/lib/api.js b/examples/with-redux-observable/lib/api.js deleted file mode 100644 index 898642dc..00000000 --- a/examples/with-redux-observable/lib/api.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Ajax actions that return Observables. - * They are going to be used by Epics and in getInitialProps to fetch initial data. - */ - -import { ajax, Observable } from './rxjs-library' -import { fetchCharacterSuccess, fetchCharacterFailure } from './reducer' - -export const fetchCharacter = (id, isServer) => - ajax({ url: `https://swapi.co/api/people/${id}` }) - .map(response => fetchCharacterSuccess(response.body, isServer)) - .catch(error => Observable.of(fetchCharacterFailure(error.response.body, isServer))) diff --git a/examples/with-redux-observable/lib/reducer.js b/examples/with-redux-observable/lib/reducer.js deleted file mode 100644 index 9801311b..00000000 --- a/examples/with-redux-observable/lib/reducer.js +++ /dev/null @@ -1,51 +0,0 @@ -import * as api from './api' -import { Observable } from './rxjs-library' - -const FETCH_CHARACTER_SUCCESS = 'FETCH_CHARACTER_SUCCESS' -const FETCH_CHARACTER_FAILURE = 'FETCH_CHARACTER_FAILURE' -const START_FETCHING_CHARACTERS = 'START_FETCHING_CHARACTERS' -const STOP_FETCHING_CHARACTERS = 'STOP_FETCHING_CHARACTERS' - -const INITIAL_STATE = { - nextCharacterId: 1, - character: {}, - isFetchedOnServer: false, - error: null -} - -export default function reducer (state = INITIAL_STATE, { type, payload }) { - switch (type) { - case FETCH_CHARACTER_SUCCESS: - return { - ...state, - character: payload.response, - isFetchedOnServer: payload.isServer, - nextCharacterId: state.nextCharacterId + 1 - } - case FETCH_CHARACTER_FAILURE: - return { ...state, error: payload.error, isFetchedOnServer: payload.isServer } - default: - return state - } -} - -export const startFetchingCharacters = () => ({ type: START_FETCHING_CHARACTERS }) -export const stopFetchingCharacters = () => ({ type: STOP_FETCHING_CHARACTERS }) - -export const fetchUserEpic = (action$, store) => - action$.ofType(START_FETCHING_CHARACTERS) - .mergeMap( - action => Observable.interval(3000) - .mergeMap(x => api.fetchCharacter(store.getState().nextCharacterId)) - .takeUntil(action$.ofType(STOP_FETCHING_CHARACTERS)) - ) - -export const fetchCharacterSuccess = (response, isServer) => ({ - type: FETCH_CHARACTER_SUCCESS, - payload: { response, isServer } -}) - -export const fetchCharacterFailure = (error, isServer) => ({ - type: FETCH_CHARACTER_FAILURE, - payload: { error, isServer } -}) diff --git a/examples/with-redux-observable/lib/rxjs-library.js b/examples/with-redux-observable/lib/rxjs-library.js deleted file mode 100644 index 94833c84..00000000 --- a/examples/with-redux-observable/lib/rxjs-library.js +++ /dev/null @@ -1,13 +0,0 @@ -// we bundle only what is necessary from rxjs library -import 'rxjs/add/operator/mergeMap' -import 'rxjs/add/operator/map' -import 'rxjs/add/operator/delay' -import 'rxjs/add/operator/takeUntil' -import { Observable } from 'rxjs/Observable' -import 'rxjs/add/observable/interval' -import ajax from 'universal-rx-request' // because standard AjaxObservable only works in browser - -export { - Observable, - ajax -} diff --git a/examples/with-redux-observable/pages/index.js b/examples/with-redux-observable/pages/index.js index 03d4347d..446917fc 100644 --- a/examples/with-redux-observable/pages/index.js +++ b/examples/with-redux-observable/pages/index.js @@ -1,15 +1,18 @@ import React from 'react' import Link from 'next/link' import withRedux from 'next-redux-wrapper' -import initStore from '../lib' -import { startFetchingCharacters, stopFetchingCharacters } from '../lib/reducer' -import * as api from '../lib/api' +import initStore from '../redux' import CharacterInfo from '../components/CharacterInfo' +import { rootEpic } from '../redux/epics' +import * as actions from '../redux/actions' +import { of } from 'rxjs/observable/of' class Counter extends React.Component { static async getInitialProps ({ store, isServer }) { - const nextCharacterId = store.getState().nextCharacterId - const resultAction = await api.fetchCharacter(nextCharacterId, isServer).toPromise() // we need to convert observable to Promise + const resultAction = await rootEpic( + of(actions.fetchCharacter(isServer)), + store + ).toPromise() // we need to convert Observable to Promise store.dispatch(resultAction) return { isServer } @@ -41,7 +44,7 @@ export default withRedux( initStore, null, { - startFetchingCharacters, - stopFetchingCharacters + startFetchingCharacters: actions.startFetchingCharacters, + stopFetchingCharacters: actions.stopFetchingCharacters }, )(Counter) diff --git a/examples/with-redux-observable/redux/actionTypes.js b/examples/with-redux-observable/redux/actionTypes.js new file mode 100644 index 00000000..e42a45c8 --- /dev/null +++ b/examples/with-redux-observable/redux/actionTypes.js @@ -0,0 +1,5 @@ +export const FETCH_CHARACTER = 'FETCH_CHARACTER' +export const FETCH_CHARACTER_SUCCESS = 'FETCH_CHARACTER_SUCCESS' +export const FETCH_CHARACTER_FAILURE = 'FETCH_CHARACTER_FAILURE' +export const START_FETCHING_CHARACTERS = 'START_FETCHING_CHARACTERS' +export const STOP_FETCHING_CHARACTERS = 'STOP_FETCHING_CHARACTERS' diff --git a/examples/with-redux-observable/redux/actions.js b/examples/with-redux-observable/redux/actions.js new file mode 100644 index 00000000..7dc47b4e --- /dev/null +++ b/examples/with-redux-observable/redux/actions.js @@ -0,0 +1,21 @@ +import * as types from './actionTypes' + +export const startFetchingCharacters = () => ({ + type: types.START_FETCHING_CHARACTERS +}) +export const stopFetchingCharacters = () => ({ + type: types.STOP_FETCHING_CHARACTERS +}) +export const fetchCharacter = isServer => ({ + type: types.FETCH_CHARACTER, + payload: { isServer } +}) +export const fetchCharacterSuccess = (response, isServer) => ({ + type: types.FETCH_CHARACTER_SUCCESS, + payload: { response, isServer } +}) + +export const fetchCharacterFailure = (error, isServer) => ({ + type: types.FETCH_CHARACTER_FAILURE, + payload: { error, isServer } +}) diff --git a/examples/with-redux-observable/redux/epics.js b/examples/with-redux-observable/redux/epics.js new file mode 100644 index 00000000..3dd962af --- /dev/null +++ b/examples/with-redux-observable/redux/epics.js @@ -0,0 +1,57 @@ +import { interval } from 'rxjs/observable/interval' +import { of } from 'rxjs/observable/of' +import { takeUntil, mergeMap, catchError } from 'rxjs/operators' +import { combineEpics, ofType } from 'redux-observable' +import ajax from 'universal-rx-request' // because standard AjaxObservable only works in browser + +import * as actions from './actions' +import * as types from './actionTypes' + +export const fetchUserEpic = (action$, store) => + action$.pipe( + ofType(types.START_FETCHING_CHARACTERS), + mergeMap(action => { + return interval(3000).pipe( + mergeMap(x => + of( + actions.fetchCharacter({ + isServer: store.getState().isServer + }) + ) + ), + takeUntil(action$.ofType(types.STOP_FETCHING_CHARACTERS)) + ) + }) + ) + +export const fetchCharacterEpic = (action$, store) => + action$.pipe( + ofType(types.FETCH_CHARACTER), + mergeMap(action => + ajax({ + url: `https://swapi.co/api/people/${store.getState().nextCharacterId}` + }).pipe( + mergeMap(response => + of( + actions.fetchCharacterSuccess( + response.body, + store.getState().isServer + ) + ) + ), + catchError(error => + of( + actions.fetchCharacterFailure( + error.response.body, + store.getState().isServer + ) + ) + ) + ) + ) + ) + +export const rootEpic = combineEpics( + fetchUserEpic, + fetchCharacterEpic +) diff --git a/examples/with-redux-observable/lib/index.js b/examples/with-redux-observable/redux/index.js similarity index 64% rename from examples/with-redux-observable/lib/index.js rename to examples/with-redux-observable/redux/index.js index 3090058b..0d71563d 100644 --- a/examples/with-redux-observable/lib/index.js +++ b/examples/with-redux-observable/redux/index.js @@ -1,17 +1,14 @@ import { createStore, applyMiddleware } from 'redux' import thunkMiddleware from 'redux-thunk' import { createLogger } from 'redux-logger' -import { combineEpics, createEpicMiddleware } from 'redux-observable' -import starwarsReducer, { fetchUserEpic } from './reducer' - -const rootEpic = combineEpics( - fetchUserEpic, -) +import { createEpicMiddleware } from 'redux-observable' +import reducer from './reducer' +import { rootEpic } from './epics' export default function initStore (initialState) { const epicMiddleware = createEpicMiddleware(rootEpic) const logger = createLogger({ collapsed: true }) // log every action to see what's happening behind the scenes. const reduxMiddleware = applyMiddleware(thunkMiddleware, epicMiddleware, logger) - return createStore(starwarsReducer, initialState, reduxMiddleware) + return createStore(reducer, initialState, reduxMiddleware) }; diff --git a/examples/with-redux-observable/redux/reducer.js b/examples/with-redux-observable/redux/reducer.js new file mode 100644 index 00000000..e4a6cea2 --- /dev/null +++ b/examples/with-redux-observable/redux/reducer.js @@ -0,0 +1,24 @@ +import * as types from './actionTypes' + +const INITIAL_STATE = { + nextCharacterId: 1, + character: {}, + isFetchedOnServer: false, + error: null +} + +export default function reducer (state = INITIAL_STATE, { type, payload }) { + switch (type) { + case types.FETCH_CHARACTER_SUCCESS: + return { + ...state, + character: payload.response, + isFetchedOnServer: payload.isServer, + nextCharacterId: state.nextCharacterId + 1 + } + case types.FETCH_CHARACTER_FAILURE: + return { ...state, error: payload.error, isFetchedOnServer: payload.isServer } + default: + return state + } +}