If you are fetching data from a server, your app needs to manage that relationship. The redux manual demonstrates the need for at least three action: FETCH_REQUEST
, FETCH_FAILURE
, and FETCH_SUCCESS
. The redux manual's reddit example shows a slightly different setup, omitting the FAILURE
and renaming SUCCESS
to RECEIVE_DATA
.
Long story short, we need to expose the API data fetching lifecycle to our app.
Fetching lifycycle:
- begin fetching
- receive data or receive error (or bail when cancelled)
- end fetching
That's a total of 4 (or 5) actions that are required for each API interaction.
Note: We'll see later that it's useful to keep "end" separate from "data" and "error". Even though "end" is implied when we have an error or if we receive data, the "end" action should be dispatched in either case.
Let's walk through the example of fetching posts. We're going to create a redux module.
A redux module is a collection of actions and reducers that relate to a specific feature. Essentially, it's a folder with a handful of files representing each facet of redux.
- actions — action creators and simple thunks
- constants — action types
- reducers — functions for managing the state for this feature
- sagas — functions for managing complex side-effects
- selectors — functions for reading values from the state for this feature
We would want to create a similar structure for every API call. We're sketching our "posts" now but you could imagine sketching out a module for each entity type by replacing all instances of "posts" with "my-entity-name" as required.
modules/posts/
actions/index.js
constants/index.js
reducers/index.js
sagas/index.js
selectors/index.js
index.js
The glue of any module is the constants. A constant is used in actions, reducers and sagas.
- Keep constants in their own file, even though it's annoying
- Give constants terse names; try to remove any namespace from the constant name
- Namespace constant values to avoid collisions with other parts of the app
export const BEGIN_FETCHING_POSTS = '@@my-app/posts/BEGIN_FETCHING_POSTS'
export const CANCEL_FETCHING_POSTS = '@@my-app/posts/CANCEL_FETCHING_POSTS'
export const END_FETCHING_POSTS = '@@my-app/posts/END_FETCHING_POSTS'
export const ERROR_FETCHING_POSTS = '@@my-app/posts/ERROR_FETCHING_POSTS'
export const FETCH_POSTS = '@@my-app/posts/FETCH_POSTS'
export const RECEIVE_POSTS = '@@my-app/posts/RECEIVE_POSTS'
Question: Should we drop the _POSTS
suffix? Probably not. Terseness is important but in this case "posts" is descriptive.
Actions are one-half of the interface between our app and our redux store (the other half are selectors). Using redux-actions
makes it dead simple to turn our constants into action creators.
- Match the action name to the constant
- Avoid creating actions as thunks
import { createAction } from 'redux-actions'
import {
BEGIN_FETCHING_POSTS,
CANCEL_FETCHING_POSTS,
END_FETCHING_POSTS,
ERROR_FETCHING_POSTS,
RECEIVE_POSTS
} from '../constants'
export const beginFetchingPosts = createAction(BEGIN_FETCHING_POSTS)
export const cancelFetchingPosts = createAction(CANCEL_FETCHING_POSTS)
export const endFetchingPosts = createAction(END_FETCHING_POSTS)
export const errorFetchingPosts = createAction(END_FETCHING_POSTS)
export const receivePosts = createAction(RECEIVE_POSTS)
import { watchActions } from 'redux-saga-watch-actions'
import {
FETCH_POSTS
} from '../constants'
import fetchPosts from './fetchPosts'
const rootSaga = watchActions({
[FETCH_POSTS]: fetchPosts
})
export default rootSaga
import { call, put, select } from 'redux-saga/effects'
import {
beginFetchingPosts,
cancelFetchingPosts,
endFetchingPosts
errorFetchingPosts
receivePosts
} from '../actions'
import { selectAnyFetching, selectCancelledByQuery, selectFetchingByQuery } from '../selectors'
const fetchPosts = function*(action) {
// TODO: bail on malformed query
const query = action.payload
// bail on already fetching (same query)
const fetching = yield select(selectFetchingByQuery(query))
if (fetching) { return }
// cancel other fetching queries (any query)
const fetchingAny = yield select(selectAnyFetching)
if (fetchingAny) {
yield put(cancelFetchingPosts())
}
// tell the app we're starting
yield put(beginFetchingPosts(query))
try {
const response = yield call(fetch, 'https://www.reddit.com/r/reactjs.json')
// bail on already cancelled
const cancelled = yield select(selectCancelledByQuery(query))
if (cancelled) { return }
const data = yield call(response.json)
yield receivePosts({ data, query })
} catch (error) {
yield errorFetchingPosts({ error, query })
}
// tell the app we're done
yield put(endFetchingPosts(query))
}
export default fetchPosts
const state = {
posts: {
data: {
'uuid-as-string': {
id: 'uuid-as-string',
type: 'posts',
attributes: { title, description, content},
relationships: {
author: { data: { id: 'uuid-as-atring' type: 'person' }, meta: {} },
comments: { data: [{ id: 'uuid-as-atring' type: 'comment' }], meta: {} },
},
meta: {
cancelled: false,
changedAttributes: { title },
changedRelationships: {},
deleted: false,
deleting: false,
fetchedAt: new Date(),
fetching: false,
savedAt: new Date(),
saving: false
}
}
},
resultSets: {
'query-as-string': {
pages: {
0: [{ id: 'uuid-as-atring' type: 'post' }]
},
meta: {
cancelled: false,
fetchedAt: new Date(),
fetching: false,
limit: 20,
offset: 0
}
}
}
}
}
import { composeSelectors, createSelector, get, memoizeSelector, withOptions } from '@comfy/redux-selectors'
export const selectRoot = createSelector('posts')
export const selectData = composeSelectors(selectRoot, 'data')
export const selectPostById = withOptions(id => composeSelectors(
selectData,
id
))
export const selectResultSets = composeSelectors(selectRoot, 'resultSets')
export const selectResultSetsByKey = withOptions(key => composeSelectors(
selectResultSets,
key
))
export const selectAllResultsByQuery = withOptions(query => {
const key = JSON.stringify(query)
return composeSelectors(
selectResultSetsByKey(key),
'pages'
memoizeSelector(pages => Object.keys(pages).reduce((all, index) => all.concat(pages[index]), []))
)
})
export const selectResultsForQueryByOffset = withOptions((query, offset = 0) => {
const key = JSON.stringify(query)
return composeSelectors(
selectResultSetsByKey(key),
'pages',
offset
)
})
export const selectCancelledByQuery = withOptions(query => {
const key = JSON.stringify(query)
return composeSelectors(
selectResultSets,
key,
'meta.cancelled'
)
})
export const selectFetchingByQuery = withOptions(query => {
const key = JSON.stringify(query)
return composeSelectors(
selectResultSets,
key,
'meta.fetching'
)
})
export const selectAnyFetching = composeSelectors(
selectResultSets,
memoizeSelector(resultSets => Object.keys(resultSets).some(key => get(state, `['${key}'].meta.fetching`)))
)
import { combineReducers } from 'redux'
import data from './dataReducer'
import resultSets from './resultSetsReducer'
const rootReducer = combineReducers({
data,
resultSets
})
export default rootReducer
import { handleActions } from 'redux-actions'
import reduceReducers from 'reduce-reducers'
import { get } from '@comfy/redux-selectors'
import {
RECEIVE_POSTS,
RECEIVE_POST,
POST_TYPE
} from '../constants'
import {
receivePost
} from '../actions'
import postReducer from './postReducer'
const initialState = {}
const validId = id => !!id
const selectPostId = action => {
const { payload: post } = action
const { id, type } = post
if (type !== POST_TYPE || !validId(id)) { return undefined }
return id
}
const keyReducerByActionSelector = (reducer, selector) => (state, action) => {
const key = selector(action)
if (key === undefined) { return state }
return {
...state,
[key]: reducer(get(state, key), action)
}
}
const mapActionToPost = keyReducerByActionSelector(postReducer, selectPostId)
const dataReducer = reduceReducers(
handleActions({
[RECEIVE_POSTS]: (state, action) => {
const { payload } = action
const { data } = payload
if (!data || !Array.isArray(data) || !data.length) { return state }
return {
...data.reduce(
(newState, post) => mapActionToPost(newState, receivePost(post)),
state
)
}
},
[RECEIVE_POST]: receivePostReducer
}, initialState),
mapActionToPost
)
export default dataReducer
import { handleActions } from 'redux-actions'
import reduceReducers from 'reduce-reducers'
import { get } from '@comfy/redux-selectors'
import {
RECEIVE_POST,
POST_TYPE
} from '../constants'
import attributesReducer from './attributesReducer'
import relationshipsReducer from './relationshipsReducer'
import postMetaReducer from './postMetaReducer'
const initialState = {}
const validId = id => !!id
const validPostAction = action => {
const { payload: post } = action
const { id, type } = post
if (type !== POST_TYPE || !validId(id)) { return false }
return true
}
const keyReducerByActionValidator = (key, reducer, validator) => (state, action) => {
const valid = validator(action)
if (valid) { return state }
return {
...state,
[key]: reducer(get(state, key), action)
}
}
const postReducer = reduceReducers(
handleActions({
[RECEIVE_POST]: (state, action) => {
const valid = validPostAction(action)
if (!valid) { return undefined }
const { payload: post } = action
return {
...state,
...post,
attributes: attributesReducer(get(state, 'attributes'), action),
relationships: relationshipsReducer(get(state, 'relationships'), action)
meta: postMetaReducer(get(state, 'meta'), action)
}
},
}, initialState),
keyReducerByActionValidator('attributes', attributesReducer, validPostAction),
keyReducerByActionValidator('relationships', relationshipsReducer, validPostAction),
keyReducerByActionValidator('meta', postMetaReducer, validPostAction)
)
export default postReducer
import { handleActions } from 'redux-actions'
import reduceReducers from 'reduce-reducers'
import { get } from '@comfy/redux-selectors'
import {
RECEIVE_POSTS,
POST_TYPE
} from '../constants'
import resultSetReducer from './resultSetReducer'
const initialState = {}
const validId = id => !!id
const queryKeyCache = new WeakMap()
const selectQueryKey = action => {
const { payload } = action
const { query } = payload
if (!query) { return undefined }
if (!queryKeyCache.has(query)) {
const key = JSON.stringify(query) // or Query.stringify
queryKeyCache.set(query, key)
}
return queryKeyCache.get(query)
return key
}
const keyReducerByActionSelector = (reducer, selector) => {
let prevResult
return (state, action) => {
const key = selector(action)
if (key === undefined) { return state }
// only merge on new result
const result = reducer(get(state, key), action)
if (result === prevResult) { return state }
prevResult = result
return {
...state,
[key]: result
}
}
}
const resultSetsReducer = keyReducerByActionSelector(resultSetReducer, selectQueryKey)
export default resultSetsReducer