Our applications have grown organically and our current collection of actions, reducers and selectors are due for an upgrade. This document is an attempt to outline an better way to organize our redux code.
The biggest changes here are the introduction of "modules" and redux-sagas.
- modules — a grouping of actions, constants, reducers, etc. that all deal with the same portion of the state.
- actions — action creators
- constants — used by action creators, reducers and sagas
- reducers — functions that update a specific portion of the state
- sagas — generator functions that handle actions, like a thunk but can work more like a reducer
- selectors — functions that read values from the state.
- redux-actions — Utility functions for creating actions and reducers. The benefit is that it encourages a standard action format and functional, testable reducers.
- redux-saga and redux-saga-watch-actions — Manage async tasks and watch for actions. Solves issues where we need to take further action when an unrelated action happens.
- @comfy/redux-selectors — Utility functions for creating memoized, composable selectors.
The biggest missing piece in this document is a story on how/if Immutable.js fits into this. We're currently using Immutable JS to wrap objects that come from our API layer. We're also using it in our reducers to enforce immutability. However, our application is somewhat uneven. There are numerous areas when object are partially immutable, or are unreliably immutable. At any given point in our app it is hard to know if the object we're dealing with is an Immutable JS object or not.
- Why I Don’t Use Immutable.js with Redux
- Immutable JS: worth the cost?
- Handling State in React: Four Immutable Approaches to Consider
Some light reading on the internet reveals that:
- Immutable.js adds a large footprint to our app bundles
- The next version of Immutable.js has been stuck in beta for a while; documentation is lacking
- Immutable.js adds complexity to our code
- Immutable.js is not required to avoid mutation: JavaScript natively supports immutable alterations with the Object/Array spread operators
The biggest benefit of Immutable.js is the use of records to do loose prop-type validation on the objects that come from our API. One possibility is to resurrect something like prop-types for reducers or json-schema for reducers to do basic state validatio. Digging a little deeper in to both solutions reveals that most people have dropped prop-type checking in favor of flowtype or typescript for reducers.
Let's dig in to this proposal. The first thing we want to do is group our actions, constants, reducers, etc. by feature instead of by type. This means we want to stop doing global src/actions/
, src/reducers/
and src/selectors/
folders.
We will create a new top-level folder, src/modules
, which will hold all of our redux "modules". A module is a logical grouping of actions, reducers, etc.; grouped by feature. In order simplify the decisions about feature grouping, the modules folder should be organized to mirror the state. Each top-level key in the redux state should have a corresponding "module" in the src/modules/
folder.
Today we have things organized by functional type. Below you can see a folder structure where high-level redux concepts each get a top-level folder in src/
. This is fine for smaller apps but can get cumbersome as apps grow. One particular issue is how related actions, constants, and selectors are separated from each other in the folder structure.
It's worth noting that the alphabetical structure of the folders has a real-world effect on how we explore the code. Anyone using the folder tree to navigate the code will run into issues when they open up the components/
folder. Suddenly the actions
and reducers
are miles away from each other in the tree view. Of course, an advanced developer knows all of the quick keys that makes it easy to find and open files. However, it shouldn't be discounted that grouping by type can make it difficult to explore the code.
Key point: Grouping by type can make it difficult to explore the code.
src/
actions/ <-- all redux actions
components/
constants/ <-- mixed app and redux constants
media/
reducers/ <-- all redux reducers
routes/
sagas/ <-- all redux sagas
selectors/ <-- all redux selectors
store/
styles/
utils/
index.js
This proposal advocates grouping redux modules together by feature. Below you can see what it looks like for a single feature, the imaginary featureName
. You can see that there are no longer any top-level src/action/
, etc. folders. Instead, the actions, etc. that apply to each feature are grouped together. In the final structure there would be a src/modules/${key}
folder for each top-level key in the redux state.
It's worth noticing that a requirement of a module, that it default export its rootReducer
, makes it much easier to compose our apps reducers together. We'll see this in more detail further down.
Key points:
- Grouping by feature keeps related code in the same place.
- Exporting the
rootReducer
makes the the store composable
src/
components/
constants/ <-- app constants (no redux constants)
media/
modules/
featureName/ <-- grouped by feature
actions/
constants/
reducers/
sagas/
selectors/
index.js <-- exports the rootReducer for this feature
index.js <-- combines all rootReducers for the store
routes/
store/
styles/
utils/
index.js
- Each top-level "key" in the state gets a module folder
- Each "module" should export a
rootReducer
by default - Each module may optionally export a
rootSaga
as well - The
modules/index.js
combines the module's reducers for the store
Each "key" in the state...
For this walk-through, let's imagine that we have a state
that looks like the following example. Each of the keys below represents a deeper part of the state tree. For this proposal we will be exploring the app
portion of the state.
// example: top-level redux state
const state = {
app, // <-- we'll be exploring this feature
experiments,
forms,
localStorage,
modals,
map,
routes,
search,
ui,
}
... gets a module folder.
Below you can see that we've created a folder for each top-level key in our state. The idea is that each "module" would contain the actions, constants, reducers, etc. for that portion of the state.
- Each module folder should have an
index.js
that default exports a reducer - The
modules/index.js
file should usecombineReducers
to create therootReducer
we use in the redux store
src/modules/
app/
experiments/
forms/
localStorage/
modals/
map/
routes/
search/
ui/
index.js <-- combines the rootReducers and rootSagas for the store
The
modules/index.js
combines the module's reducers for the store
- Notice that we import the
rootReducer
from every "module" and combine them. - Notice that we import the
rootSaga
too! - Notice that every module has a reducer but not every module has a saga
import { combineReducers } from 'redux'
import { combineSagas } from 'redux-saga-watch-actions'
import app, { rootSaga as appSaga } from './app'
import experiments from './experiments'
import forms from './forms'
import localStorage from './localStorage'
import modals from './modals'
import map, { rootSaga as mapSaga } from './map'
import routes from './routes'
import search from './search'
import ui, { rootSaga as uiSaga } from './ui'
const rootReducer = combineReducers({
app,
experiments,
forms,
localStorage,
modals,
map,
routes,
search,
ui,
})
export const rootSaga = combineSagas(appSaga, mapSaga, uiSaga)
export default rootReducer
Each top-level module will be expected to have a folder for each of the module concepts. You can see below that our app/
module contains folders for actions, constants, reducers, etc.
src/modules/app/
actions/
constants/
reducers/ <-- exports a rootReducer
sagas/ <-- exports a rootSaga
selectors/
index.js <-- exports the rootReducer and rootSaga for this module
- A module's
index.js
- should export a
rootReducer
that combines the child reducers of that module's state tree - may optionally export a rootSaga that combines all of the sagas
- should not export actions, constants or selectors
- should export a
- Each folder (
actions/
,constants/
,reducers/
, etc.) should contain an index file that exports the interface for the top-level of that module's state tree.
Here we'll explore the shape for the app
portion of the redux state. Every module has a different focus; here we're imagining that the app
is responsibility for basic housekeeping related to the app as a whole. Other parts of the state, like the map
relate to more specific features.
We're designing our app state to handle some basic values related to bootstrapping the app and managing the current user. Notice that hasMounted
and isMobile
are simple booleans while user
is a deeply complex object.
- Each key in the state gets its own reducer
- Deeply nested objects get deeply nested reducers
const state = {
hasMounted,
isMobile,
user: {
addresses: {
billing: {
// ...
},
home: {
// ...
},
},
email,
firstName,
lastName,
meta: {
error,
isLoading,
loadedAt,
},
phone,
},
}
Before we get too deep into this, let's take a look at the final structure of our app module.
- Every concept gets its own folder
- Every key gets its own reducer
- Complex objects get dedicated selectors
src/modules/app/
actions/index.js
constants/index.js
reducers/
userReducer/
addresses.js
index.js
metaReducer.js
index.js
sagas/
fetchUserSaga.js
loginUserSaga.js
logoutUserSaga.js
index.js
selectors/
index.js
user.js
index.js
Following along with our app example, let's look at the actions/index.js
file.
- Create action creators using
createAction
- Prefer props objects for complex payloads
- Always name the action to match the constant exactly
- Prefer actions that describe what happened/should happen
hasMounted
— declares that the app is fully mountedbeginFetchingUser
— declares that the process for fetching a user is beginning. You can infer from this that the user is currently loading, used to toggle theuser.meta.isLoading
value.loginUser
— handled by a saga, manages the process of validating and fetching a user's profile data.
- Avoid
setFoo
unless there's no better name. The goal is to have descriptive actions that describe state changes, not dictate. - Never create an action by hand; always use an action creator to create an action
You can see below that we're importing constants and using createAction
to create standardized action creators. You can also see that we have some lifecycle actions for the process of fetching and receiving the user.
Using createAction
allows us to quickly mock up our action creators. As an additional benefit, it enforces the use of the flux standard actions specification, which specifies how action objects should look. The end result is that we can quickly create high-quality, standardized action creators.
Key point: createAction
makes it painless to create high-quality, feature rich action creators.
import { createAction } from 'redux-actions'
import {
BEGIN_FETCHING_USER,
END_FETCHING_USER,
ERROR_FETCHING_USER,
FETCH_USER,
HAS_MOUNTED,
IS_MOBILE,
LOGIN_USER,
LOGOUT_USER,
RECEIVE_USER,
} from '../constants'
export const hasMounted = createAction(HAS_MOUNTED)
export const isMobile = createAction(IS_MOBILE)
export const loginUser = createAction(LOGIN_USER)
export const logoutUser = createAction(LOGOUT_USER)
export const fetchUser = createAction(FETCH_USER)
export const beginFetchingUser = createAction(BEGIN_FETCHING_USER)
export const endFetchingUser = createAction(END_FETCHING_USER)
export const errorFetchingUser = createAction(ERROR_FETCHING_USER)
export const receiveUser = createAction(RECEIVE_USER)
Any action creator created using createAction
will have a type
and a payload
. It will take the first argument and make that the payload. Below you can see that you can pass nothing, pass scalar values or pass in complex objects. In every case, the arguments become the action payload. There is an ability to construct custom payloads using a payloadCreator
function if you desire; but the default payload creator covers most use cases.
At first glance it might feel wrong that the payload is created in such a loose manner. In practice, this is less of a concern. The important part is establishing a consistent convention in your code base.
Key point: always use a props object as your payload unless your passing a single scalar value.
// example: exploring how action creators work
import { hasMounted, setMobile, receiveUser, errorFetchingUser } from 'modules/app/actions'
// ✅ pass nothing; no payload
hasMounted() // { type: HAS_MOUNTED }
// ✅ pass a scalar value; simple payload
isMobile(true) // { type: IS_MOBILE, payload: true }
// ✅ Prefer: use a props object to pass complex data
receiveUser({ user }) // { type: RECEIVE_USER, payload: { user } }
// ❌ Avoid: using complex objects directly as the payload
receiveUser(user) // { type: RECEIVE_USER, payload: user }
// ❌ Avoid: passing multiple args; requires custom payload creator
receiveUser(user, headers, jobId) // { type: RECEIVE_USER, payload: user }
// ✅ Prefer: use a props object
receiveUser({ user, headers, jobId }) // { type: RECEIVE_USER, payload: { user, headers, jobId } }
// ✅ pass errors
const error = new Error('whoops!')
errorFetchingUser(error) // { type: ERROR_FETCHING_USER, payload: error, error: true }
Constants are probably the most annoying part of a redux application. We typically only use constants indirectly. It can feel painful to create a constant only to immediately jam it into an action creator. At the same time, constants are the glue that holds the app together.
It's important to see constants as separate from actions and reducers. It is a common mistake to lump constants into your actions files or into your reducer files. In practice, a constant is used in actions, reducers and sagas and deserves a place along side them.
- Treat constants as a top-level concept alongside
actions
,reducers
andsagas
. - Namespace your constants
- Prefer actions that describe what happened
- an action declares what happened
- a reducer decides what should happen
- a saga will take additional action
- Avoid
SET_FOO
unless there's no better name;SET_FOO
is a code smell test that indicates you are misusing redux.
Below you can see the example constants we used in our action creators. Notice that we namespace all of the constants to avoid name collisions with any other modules. For instance, if another module had the concept of HAS_MOUNTED
it would be possible to accidentally trigger that action in both places. Using namespaces avoids any possibility of collisions.
- Notice that they are grouped together by use; not alphabetized
- Some of these constants are for reducers, others for sagas
export const HAS_MOUNTED = '@@package-name/app/HAS_MOUNTED'
export const IS_MOBILE = '@@package-name/app/IS_MOBILE'
export const LOGIN_USER = '@@package-name/app/LOGIN_USER'
export const LOGOUT_USER = '@@package-name/app/LOGOUT_USER'
export const FETCH_USER = '@@package-name/app/FETCH_USER'
export const BEGIN_FETCHING_USER = '@@package-name/app/BEGIN_FETCHING_USER'
export const END_FETCHING_USER = '@@package-name/app/END_FETCHING_USER'
export const ERROR_FETCHING_USER = '@@package-name/app/ERROR_FETCHING_USER'
export const RECEIVE_USER = '@@package-name/app/RECEIVE_USER'
For beginners, reducers are pretty confusing. The boilerplate reducer code uses switch cases and leaves a lot up to the implementor. Developers that are new to redux are often flummoxed and end up crafting unwieldy, difficult to maintain reducers.
However, reducers can become quite simple when you follow a few guidelines. One the biggest mistakes a redux developer can make is to create one massive switch/case reducer for each part of the state. In reality, there should be a reducer for every key in the state tree. Breaking your reducers into smaller functions will greatly reduce the complexity of your reducers.
- Use
handleActions
andcombineReducers
to create your reducers - Avoid switch/case
- Prefer small reducer files (less than 100 lines)
- One reducer per file (usually)
- Each reducer handles one key
- Sub keys each get their own reducer
- Each reducer is a function
- Use
combineReducers
to sketch out the shape of your state tree - Use
handleActions
to capture multiple actions for individual keys - Use
handleAction
when your key only ever deals with one action - Use separate reducers for complex keys (any key with sub keys)
Each reducer is a function
- Notice that the
hasMounted
andisMobile
reducers both usehandleAction
to manage a single action - Notice that the
hasMounted
reducer completely ignoresstate
andaction
and simply returnstrue
- Notice that the
isMobile
reducer expects a payload, but assumestrue
if the payload is missing - Notice that the
user
key is punted to theuserReducer
import { combineReducers } from 'redux'
import { handleAction } from 'redux-actions'
import {
HAS_MOUNTED,
IS_MOBILE,
} from '../constants'
import userReducer from './userReducer'
const rootReducer = combineReducers({
hasMounted: handleAction(HAS_MOUNTED, () => true, false),
isMobile: handleAction(
IS_MOBILE,
(state, action) => {
const { payload } = action
return payload === undefined ? true : payload
},
false
),
user: userReducer,
})
export default rootReducer
Each key gets its own reducer
Our userReducer
needs to handle multiple complex keys, so we create a folder for it. It's important to notice that we never simply spread the user
object into the state. Instead, we have an individual reducer for every sub key of the user object. This allows us to be very precise about how the payload is merged into the state.
Individual key reducers allows us to handle keys that need special consideration. Imagine if the email
key came back from the API as "username".
Key-level reducers are especially helpful for the addressesReducer
, which handles the various aspects of the address, which is likely quite complex. We're able to punt that complexity up the tree, which makes the reducer code and each level of the tree much easier to maintain.
- Notice the
userKeyReducer
is configurable, allowing the same reducer to be used for multiple keys - Notice the
addressesReducer
handles theRECEIVE_USER
action itself - Notice the
metaReducer
manages meta data for theuser
Key points:
- Each key gets its own reducer
- Complex keys get separate reducers
import { combineReducers } from 'redux'
import { handleAction } from 'redux-actions'
import {
RECEIVE_USER,
} from '../../constants'
import addressesReducer from './addressReducer'
import metaReducer from './metaReducer'
const userKeyReducer = (key) => handleAction(
RECEIVE_USER,
(state, action) => {
const { payload } = action
const { user } = payload
return user[key]
},
null
)
const userReducer = combineReducers({
addresses: addressesReducer,
email: userKeyReducer('username'),
firstName: userKeyReducer('firstName'),
lastName: userKeyReducer('lastName'),
meta: metaReducer,
phone: userKeyReducer('phone'),
})
export default userReducer
Use
handleActions
to capture multiple actions for individual keys
- Use
handleActions
for managing multiple actions - Use
handleAction
for managing single actions - Notice the
error
reducer is receiving a special "error" action - Notice the
loadedAt
reducer is looking forRECEIVE_USER
to create a timestamp
import { combineReducers } from 'redux'
import { handleAction, handleActions } from 'redux-actions'
import {
BEGIN_FETCHING_USER,
END_FETCHING_USER,
ERROR_FETCHING_USER,
RECEIVE_USER,
} from '../../constants'
const metaReducer = combineReducers({
isLoading: handleActions({
[BEGIN_FETCHING_USER]: () => true,
[END_FETCHING_USER]: () => false
}, null),
error: handleAction(
ERROR_FETCHING_USER,
(state, action) => action.error
null
),
loadedAt: handleAction(
RECEIVE_USER,
() => Date.now()
null
)
})
export default metaReducer
A saga is a somewhat advanced concept. Sagas cover most of the ground that thunks cover but they have some additional features that make them desirable. Typically an app will need both thunks and saga to cover different cases. If you are new to sagas you may be intimidated the usage of generator functions. In practice, redux-saga handles all of the annoying bits of working with generators.
The big advantage of sagas is that they can listen for actions that are also handled by reducers. For instance, if the MapContainer
dispatches an updateCenter
action, that could be caught by the a reducer in the map
module and by a saga (in a totally different module) that fetches data for the side bar.
Here we're demonstrating the sagas for our app module.
The index file for a module's sagas is typically a simple mapping of actions to sagas. Sagas, like reducers, need to be attached to the actions that trigger them. Here we're using a simple utility function that handles that use-case much like handleActions
does for a reducer.
Redux-saga also provides many advanced options for complex scenarios that are not typical. If you need to manage something like real-time messaging or one-off saga, you might enjoy exploring the docs.
Here you can see that we're mapping our async actions to our sagas. It's important to note that sagas can call each other. For instance, imagine that our loginUserSaga
authenticates the user and then fetches their profile. In that scenario, the loginSaga
would likely be the only place in the app that dispatches the fetchUser
action.
- Use
watchActions
to map a saga to an action - Use
combineSagas
(not shown here) to merge sagas together - Each saga gets its own file
- Export a default
rootSaga
from yoursagas/index.js
import { watchActions } from 'redux-saga-watch-actions'
import {
FETCH_USER,
LOGIN_USER,
LOGOUT_USER,
} from '../constants'
import fetchUserSaga from './fetchUserSaga'
import loginUserSaga from './loginUserSaga'
import logoutUserSaga from './logoutUserSaga'
const rootSaga = watchActions({
[FETCH_USER]: fetchUserSaga,
[LOGIN_USER]: loginUserSaga,
[LOGOUT_USER]: logoutUserSaga,
})
export default rootSaga
Each saga gets its own file
A saga is a generator function that yields special "effects" that redux-saga responds to. In practice, you can do everything in a saga that you can do in a thunk but you can use a friendly async styles instead of writing promise chains. Because redux-saga runs your generator for you, it feels more like using async/await to manage asyncronous actions, like API calls. Under the hood, redux-saga relies on the advanced features of generators to make this work.
One stumbling block for saga is the use of effects. For instance, you can't dispatch
actions, you need to put
them instead. In practice, once you realize that put
is the same as dispatch
, it's easy to convert a thunk into a saga.
The big benefits of sagas are the friendly async syntax (no promise chains) and the ability to capture actions.
Here you can see a pretty standard API request. We use lifecycle actions to let the app know how the API request is proceeding. This allows for us to show loading and error messages as necessary. Once we have the data, we dispatch it to the reducer with the receiveUser
action.
- Use
put
like you woulddispatch
; remember toyield put(...)
- Use
select
(sort of) like you wouldgetState
- Use
call
to call external functions, especially async calls - Notice
const user = yield result.json()
- Anything you
yield
will be managed by redux-saga - If you yield a promise, the result will be returned
- Anything you
import { call, put } from 'redux-saga/effects'
import {
beginFetchingUser,
endFetchingUser,
errorFetchingUser,
receiveUser,
} from '../actions'
const fetchUserSaga = function*(action) {
yield put(beginFetchingUser())
try {
const { payload } = action
const username = payload
const url = `${API_URL}/user/${username}`
const result = yield call(fetch, url)
const user = yield result.json()
yield put(receiveUser({ user }))
} catch (error) {
yield put(errorFetchingUser(error))
}
yield put(endFetchingUser())
}
export default
Selectors are often neglected in a redux application. It's all too common for developers to manually select values from deep in the redux state. Writing ad-hoc selectors inside your components is an anti-pattern that can make refactoring impossible.
Selector functions make it easier to refactor your app later if the state needs to change shape. Common issues, like missing properties, can be handled centrally instead of deep within the app, everywhere they're needed.
Here we use @comfy/redux-selectors
to smooth over some of the boilerplate of creating high quality selectors. It has some advanced features that make it easy to create complex, well memoized selectors. For the example here, using createSelector
means never having to check for the existence of a key. It will return undefined
if a key doesn't exist, even a deep key.
- Name all selectors like
selectFoo
- Never name a selector like
getFoo
- Avoid the temptation to name selectors like
fooSelector
- Use
createSelector
to create simple selectors - Use
composeSelectors
to chain selectors together - Notice that
selectHasMounted
composesselectApp
- Notice that
selectUserIsLoading
composesselectUser
withselectIsLoading
import { createSelector, composeSelectors } from '@comfy/redux-selectors'
import {
selectAddresses,
selectHomeAddress,
selectBillingAddress,
selectEmail,
selectFirstName,
selectLastName,
selectPhone,
selectIsLoading,
selectLoadedAt,
selectError,
} from './user'
export const selectApp = createSelector('app')
export const selectHasMounted = composeSelectors(selectApp, 'hasMounted')
export const selectIsMobile = composeSelectors(selectApp, 'isMobile')
export const selectUser = composeSelectors(selectApp, 'user')
export const selectUserAddresses = composeSelectors(selectUser, selectAddresses)
export const selectUserHomeAddress = composeSelectors(selectUser, selectHomeAddress)
export const selectUserBillingAddress = composeSelectors(selectUser, selectBillingAddress)
export const selectUserEmail = composeSelectors(selectUser, selectEmail)
export const selectUserFirstName = composeSelectors(selectUser, selectFirstName)
export const selectUserLastName = composeSelectors(selectUser, selectLastName)
export const selectUserPhone = composeSelectors(selectUser, selectPhone)
export const selectUserIsLoading = composeSelectors(selectUser, selectIsLoading)
export const selectUserLoadedAt = composeSelectors(selectUser, selectLoadedAt)
export const selectUserError = composeSelectors(selectUser, selectError)
Selectors simply read values from the state
that is passed into them.
- Notice that
selectAddresses
expectsstate
to be a user object. This enables this selector to be used deep within a component if you have auser
object and need to read theaddresses
key. - Notice that
selectIsLoading
composesselectMeta
. Again, this allows for selecting themeta.isLoading
value directly from theuser
object.
import { createSelector, composeSelectors } from '@comfy/redux-selectors'
export const selectAddresses = createSelector('addresses')
export const selectHomeAddress = composeSelectors(selectAddresses, 'home')
export const selectBillingAddress = composeSelectors(selectAddresses, 'billing')
export const selectEmail = createSelector('email')
export const selectFirstName = createSelector('firstName')
export const selectLastName = createSelector('lastName')
export const selectPhone = createSelector('phone')
export const selectMeta = createSelector('meta')
export const selectIsLoading = composeSelectors(selectMeta, 'isLoading')
export const selectLoadedAt = composeSelectors(selectMeta, 'loadedAt')
export const selectError = composeSelectors(selectMeta, 'error')