diff --git a/examples/with-redux-observable/README.md b/examples/with-redux-observable/README.md new file mode 100644 index 00000000..f3812b7e --- /dev/null +++ b/examples/with-redux-observable/README.md @@ -0,0 +1,41 @@ +# Redux-Observable example + +## How to use + +Download the example [or clone the repo](https://github.com/zeit/next.js): + +```bash +curl https://codeload.github.com/zeit/next.js/tar.gz/master | tar -xz --strip=2 next.js-master/examples/with-redux-observable +cd with-redux-observable +``` + +Install it and run: + +```bash +npm install +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. + +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. + +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. + +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). + diff --git a/examples/with-redux-observable/components/CharacterInfo.js b/examples/with-redux-observable/components/CharacterInfo.js new file mode 100644 index 00000000..226f8238 --- /dev/null +++ b/examples/with-redux-observable/components/CharacterInfo.js @@ -0,0 +1,43 @@ +import React from 'react' +import { connect } from 'react-redux' + +const CharacterInfo = ({character, error, fetchCharacter, isFetchedOnServer = false}) => ( +
+ { + error ?

We encountered and error.

+ :
+

Character: {character.name}

+

birth year: {character.birth_year}

+

gender: {character.gender}

+

skin color: {character.skin_color}

+

eye color: {character.eye_color}

+
+ + } +

+ ( was character fetched on server? - + {isFetchedOnServer.toString()}) +

+ +
+) + +export default connect( + state => ({ + character: state.character, + error: state.error, + isFetchedOnServer: state.isFetchedOnServer + }), +)(CharacterInfo) diff --git a/examples/with-redux-observable/demo.png b/examples/with-redux-observable/demo.png new file mode 100644 index 00000000..2fee5511 Binary files /dev/null and b/examples/with-redux-observable/demo.png differ diff --git a/examples/with-redux-observable/lib/api.js b/examples/with-redux-observable/lib/api.js new file mode 100644 index 00000000..898642dc --- /dev/null +++ b/examples/with-redux-observable/lib/api.js @@ -0,0 +1,12 @@ +/** + * 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/index.js b/examples/with-redux-observable/lib/index.js new file mode 100644 index 00000000..3090058b --- /dev/null +++ b/examples/with-redux-observable/lib/index.js @@ -0,0 +1,17 @@ +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, +) + +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) +}; diff --git a/examples/with-redux-observable/lib/reducer.js b/examples/with-redux-observable/lib/reducer.js new file mode 100644 index 00000000..9801311b --- /dev/null +++ b/examples/with-redux-observable/lib/reducer.js @@ -0,0 +1,51 @@ +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 new file mode 100644 index 00000000..94833c84 --- /dev/null +++ b/examples/with-redux-observable/lib/rxjs-library.js @@ -0,0 +1,13 @@ +// 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/package.json b/examples/with-redux-observable/package.json new file mode 100644 index 00000000..8883d789 --- /dev/null +++ b/examples/with-redux-observable/package.json @@ -0,0 +1,25 @@ +{ + "name": "with-redux-observable", + "version": "1.0.0", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "author": "tomaszmularczyk(tomasz.mularczyk89@gmail.com)", + "dependencies": { + "next": "latest", + "next-redux-wrapper": "^1.0.0", + "react": "^16.0.0", + "react-dom": "^16.0.0", + "react-redux": "^5.0.1", + "redux": "^3.6.0", + "redux-logger": "^3.0.6", + "redux-observable": "^0.17.0", + "redux-thunk": "^2.1.0", + "rxjs": "^5.5.2", + "superagent": "^3.8.1", + "universal-rx-request": "^1.0.3" + }, + "license": "ISC" +} diff --git a/examples/with-redux-observable/pages/index.js b/examples/with-redux-observable/pages/index.js new file mode 100644 index 00000000..03d4347d --- /dev/null +++ b/examples/with-redux-observable/pages/index.js @@ -0,0 +1,47 @@ +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 CharacterInfo from '../components/CharacterInfo' + +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 + store.dispatch(resultAction) + + return { isServer } + } + + componentDidMount () { + this.props.startFetchingCharacters() + } + + componentWillUnmount () { + this.props.stopFetchingCharacters() + } + + render () { + return ( +
+

Index Page

+ +
+ +
+ ) + } +} + +export default withRedux( + initStore, + null, + { + startFetchingCharacters, + stopFetchingCharacters + }, +)(Counter) diff --git a/examples/with-redux-observable/pages/other.js b/examples/with-redux-observable/pages/other.js new file mode 100644 index 00000000..85db3fcd --- /dev/null +++ b/examples/with-redux-observable/pages/other.js @@ -0,0 +1,13 @@ +import React from 'react' +import Link from 'next/link' + +const OtherPage = () => ( +
+

Other Page

+ + Get back to "/" + +
+) + +export default OtherPage