app state, view state and regional state
separating components and containers
and code-splitting reducers
Zumper loves redux.
- Regional state: The problem we're trying to solve
- React-redux, react hooks, react-redux hooks
- The best thing about redux: the boilerplate
- How to implement your own regional redux
- Bonus: Using redux modules
- Bonus: Using redux modules in react components
- Bonus: use hooks for containers
- Bonus: Code-splitting reducers
In a traditional redux application you are pressured to "lift" all shared app state into the global redux store. Generally this is great advice but it doesn't always work.
There are times when a small part of your app needs to share state with some child components... but that state is not a good fit for redux.
We first ran into this problem on our redesigned floorplan viewer: https://www.zumper.com/apartment-buildings/p255216/kips-bay-court-kips-bay-new-york-ny
Problems:
- The
Floorplan
component is repeated. - Each
Floorplan
state is distinct. - No
Floorplan
state is shared with the broader app. - Representing all floorplan state in global redux is complicated!
Key point: Each Floorplan
has its own "regional" state.
We decided to invent a way to work with regional state in a similar way to how we already use redux. Under the hood we use react context to share a custom store with a small region of the page.
We designed it to work with react-redux so that we would maintain all performance enhancements and good code structure.
https://www.npmjs.com/package/module-react-redux
- Factory for creating regional state that works like react-redux
- We designed it to keep all of the best parts of redux modules and react-redux
- Initially we used
useReducer
, then we useduseEnhancedReducer
(to get access to thunks) - Finally we just used redux
We will be updating this module once the next version of react-redux is released. We want to provide custom hooks to fully match the react-redux API.
https://github.com/zumper/module-react-redux/issues/5
NOTE: We called it module-react-redux
because it is a factory that allows you to create an instance of react-redux bound to a specific redux module.
Internally we refer to this technique as "taedux" because @xoddong (Tae) is the engineer that initially worked on this.
- Regional state should only be relevant to a small component tree
- Regional state should never rely on SSR or preloading
- Regional state should usually derive only from user interactions
- Regional state is best when it is used in an ephemeral component tree
NOTE: Keep in mind that you can blend global redux state and regional state together.
NOTE: Avoid pushing regional state into app state.
React-redux is an efficient wrapper around react context. It exposes some standard interfaces for connecting a redux store to a react component.
Now there's hooks. There's useReducer
built into react and there's useSelector
and useDispatch
in the latest version of react-redux.
Is connect
dead? Most people say no.
When we're talking about using hooks to replace redux we need to be careful. There are many cases where redux is still the best option. At the same time, we found that some things are “too hard” to put in the global app state (which is why we invented taedux).
useReducer
and context do not replace redux- Hooks are great for regional state
- The new react-redux hooks have some pitfalls that
connect
smooths over
It's helpful to understand the types of state we're trying to manage so that we can decide which tool is best suited for the job.
- app state: shared with the entire app (redux)
- view state: specific to a view (redux)
- regional state: specific to a particular part of the page (regional store)
In our apps we've found that redux is a great fit for "app" and "view" state. Whenever state needs shared with the whole app or disparate parts of the page, redux is a great tool.
We've landed on a redux state shape that looks something like this:
const state = {
app, // <-- meta data about the entire app
resources, // <-- fetched from an API
modals, // <-- a type of view
views, // <-- meta data about a specific view
}
NOTE: To get an idea of how to use resources
, check out a gist I made.
It can be confusing when you first start using redux but the boilerplate is a feature, not a bug.
Key features:
- separates code into small, testable chunks
- implies a good code structure that scales well
- designed to be maintainable
We follow an expanded version of the ducks-modular-redux pattern. The ducks module pattern recognizes that actions, reducers and selectors often work in concert with a specific part of the state.
(more on this in the bonus section)
Regional state works best when you have a component that needs to share state with its children (but not the whole app).
Key goals:
- Create a provider context to share state with a component tree
- Use redux-like code patterns to manage shared state
- Use react-redux-like code patterns for connecting to the regional store
We will be working with a component tree that looks like this:
src/
components/
MyRegion/
module/ <-- a redux module _within a component_
DeepContainer.js
Deep.js
index.js
connectMyRegion.js
MyRegionContext.js
MyRegionProvider.js
MyRegion.js
// components/MyRegion/MyRegionContext.js
import { createContext } from 'react'
export const MyRegionContext = createContext()
We're going to be copying the Provider
pattern from react-redux. We're going to build it using react context and hooks.
Here's a rough sketch of how it works.
Our provider relies on useReducer
to manage regional state. We use a normal redux module to manage the reducer. We use react context to make this reducer available to the sub components.
// components/MyRegion/MyRegionProvider.js
import React, { useEffect, useReducer, useRef } from 'react'
// our regional module
import { reducer } from './module'
// our regional context
import { MyRegionContext } from './MyRegionContext'
const initialState = { greeting: 'hello' }
export const MyRegionProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState)
// create an imitation redux store
const storeRef = useRef({
dispatch,
getState: () => state,
subscribe: () => undefined, // TODO: implement subscribe
replaceReducer: () => undefined,
})
useEffect(() => {
storeRef.current = {
...storeRef.current,
dispatch,
}
}, [dispatch])
const store = storeRef.current
// provide the store to this component tree
return <MyContext.Provider value={store}>children</MyContext.Provider>
}
The component that defines our region needs to wrap all of its children in our region provider. Here you can see we are wrapping our Deep
component in our provider.
// components/MyRegion/MyRegion.js
import React from 'react'
import { MyRegionProvider } from './MyRegionProvider'
import { Deep } from './Deep'
export const MyRegion = () => {
return (
<MyRegionProvider>
<Deep />
</MyRegionProvider>
)
}
Now we can use our store in a deep component.
// components/MyRegion/Deep.js
import React, { useContext } from 'react'
import { MyRegionContext } from './MyRegionContext'
import { selectGreeting } from './module/selectors'
import { toggleGreeting } from './module/actions'
export const Deep = () => {
// compare with useStore from react-redux
const { dispatch, getState } = useContext(MyRegionContext)
const state = store.getState()
// mapStateToProps
const greeting = selectGreeting(state)
// mapDispatchToProps
const onClick = () => dispatch(toggleGreeting())
return <button onClick={onClick}>{greeting}</button>
}
Cool... but this is really ugly. We're blending our container with our component. We're also missing out on some subtle performance enhancements. We can start to decorate this with useMemo
and useCallback
but there's a better way.
Not everyone realizes that react-redux connect
can take an optional context
. This allows us to create a thin wrapper around connect
that will always connect to our regional context.
import { connect } from 'react-redux'
export const createConnector = (context) => {
return (mapStateToProps, mapDispatchToProps, mergeProps, options) => (
WrappedComponent
) =>
connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
{ ...options, context }
)(WrappedComponent)
}
Now we can create a connectMyRegion
that uses react-redux to connect to our regional store.
// components/MyRegion/connectMyRegion.js
import { createConnector } from 'utils/module'
import { MyRegionContext } from './MyRegionContext'
export const connectMyRegion = createConnector(MyRegionContext)
We can make our deep component really simple.
// components/MyRegion/Deep.js
import React from 'react'
export const Deep = ({ greeting, onClick }) => {
return <button onClick={onClick}>{greeting}</button>
}
And move all of the connect logic to the container.
// components/MyRegion/DeepContainer.js
import { connectMyRegion } from './connectMyRegion'
import { selectGreeting } from './module/selectors'
import { toggleGreeting } from './module/actions'
import { Deep } from './Deep'
const mapMyRegionStateToProps = (state) => {
return {
greeting: selectGreeting(state),
}
}
const mapMyRegionDispatchToProps = (dispatch) => {
return {
onClick: () => dispatch(toggleGreeting()),
}
}
export const DeepContainer = connectMyRegion(
mapMyRegionStateToProps,
mapMyRegionDispatchToProps
)(Deep)
And now we use the container in our view component.
// components/MyRegion/MyRegion.js
import React from 'react'
import { MyRegionProvider } from './MyRegionProvider'
import { DeepContainer } from './DeepContainer'
export const MyRegion = () => {
return (
<MyRegionProvider>
<DeepContainer />
</MyRegionProvider>
)
}
Let's take a step back
One of our design goals with regional state is to use the same code structure we use for redux modules, but without necessarily using redux.
If you're excited about useReducer
then you'll be doubly excited that it enables the same good state management patterns we use in redux... but with regional state.
We follow an expanded version of the ducks-modular-redux pattern. The ducks module pattern recognizes that actions, reducers and selectors often work in concert with a specific part of the state.
Organized by function
In a typical redux app you would structure your code so that all actions, reducers and selectors are properly grouped into folders by type. Organizing "by function" means that all code that does the same type of thing is grouped together. It leads a folder structure like what you see below.
src/
action/ <-- all actions
components/
constants/
reducers/ <-- all reducers
routes/
selectors/ <-- all selectors
store/
utils/
Key point: This setup gets cumbersome over the long term. The problem is that disparate functionality gets lumped together.
You end up with a huge pile of actions lumped into one file.
// are these actions related to each other?
export const toggleFoo = createAction(TOGGLE_FOO)
export const toggleBar = createAction(TOGGLE_BAR)
export const toggleBaz = createAction(TOGGLE_BAZ)
Key point: Grouping all actions into the same file/folder makes it hard to understand your app.
Organized by feature
With Ducks Modular Redux, you group actions, reducers and selectors by feature. By grouping the actions and selectors with the reducer they manage you can marry your state shape to the code that manages it.
You end up with a folder structure like this:
src/
components/
constants/
redux/modules/ <-- grouped by feature
app.js
myView.js <-- manages state.myView
routes/
store/
utils/
Key point: Organizing by feature makes your app easier to maintain.
We prefer to separate actions, reducers and selectors into their own folders within the module. We blend the traditional folder style with the redux modules pattern.
We do this to give our modules room to grow. At Zumper we try to add in an "editing surface" to make it easier to add new things to the code.
src/modules/app/
actions/
index.js <-- all actions for this module
constants/
index.js
reducers/
index.js
selectors/
index.js
index.js <-- we can re-export everything to be like "classic" ducks
The main benefit is scalability. We're giving our code room to grow.
- each module consists of the same basic folder structure
- group actions, reducers and selectors into their own folders
- for each leaf of the state tree, make a new file and re-export from index.js
Let's look at our redux state shape for state.app
.
We want to keep shared meta data for the entire app in our app
state. You can see that we keep a few things there.
geoLocation
: where the user is locatedinitialLocationChanged
: are we still on the initial route?mobile
: do we think we're on a small screen?mounted
: has the app fully hydrated?
const state = {
app: {
geoLocation: {
lat,
lng,
city,
state,
},
initialLocationChanged: false,
mobile: false,
mounted: false,
},
myView,
}
If we look, we can see that geoLocation
is way more complex. We might split the code dealing with geoLocation
into a new file.
Key point: Generally, it's wise to split out your modules for each leaf of your state tree.
src/modules/app/
actions/
index.js <-- re-exports geoLocation actions
geoLocation.js <-- only actions dealing with geoLocation
constants/
index.js
geoLocation.js
reducers/
index.js
geoLocation.js <-- always split reducers for each leaf of the tree
selectors/
index.js
geoLocation.js
index.js
NOTE: We usually separate reducers for each leaf of the state tree. We don't always split out actions, constants or selectors.
We know for sure these actions work with the same part of the state.
// modules/app/actions/index.js
import { createAction } from 'redux-actions'
import {
APP_MOUNTED,
APP_IS_MOBILE,
APP_IS_NOT_MOBILE,
INITIAL_LOCATION_CHANGED,
} from '../constants'
// re-export the geoLoaction actions
export * from './geoLocation'
// keep other actions in the index file
export appMounted = createAction(APP_MOUNTED)
export appIsMobile = createAction(APP_IS_MOBILE)
export appIsNotMobile = createAction(APP_IS_NOT_MOBILE)
export initialLocationChanged = createAction(INITIAL_LOCATION_CHANGED)
In the future we could consider moving geoLocation
into its own sub-module.
src/modules/app/
action/
constants/
modules/
geoLocation/ <-- you can nest modules if you like
reducers/
selectors/
NOTE: I'm taking an indulgent side-track here.
We keep our containers separate from our components.
src/
components/
App.js <-- renders props
AppContainer.js <-- connects to the store
constants/
modules/
routes/
store/
utils/
- Keep components focused on two things
- Rendering props
- Firing events
- Keep containers focused on two things
- Selecting from state
- Dispatching actions
A component should only deal rendering props and properly firing events. What the event does is the job of a container.
import React, { useEffect } from 'react'
export const App = ({ onMount }) => {
useEffect(() => {
onMount()
}, [])
// ... add hooks for other app features
return <div>hello</div>
}
import { connect } from 'react-redux'
import { appMounted } from 'modules/app/actions'
import { selectIsMobile } from 'modules/app/selectors'
import { App } from './App'
const mapStateToProps = (state) => {
return {
mobile: selectIsMobile(state),
}
}
const mapDispatchToProps = (dispatch) => {
return {
onMount: () => {
dispatch(appMounted())
},
}
}
export const AppContainer = connect(
mapStateToProps,
mapDispatchToProps
)(App)
We love the connect
pattern and use it for react-router and regional state.
We've created a handful of helpers that allow us to borrow patterns from react-redux for other sources of state.
The idea is that we want to pass ready-to-render props into our components. We want to do the work of deriving props from a data source somewhere else.
Here's a kitchen sink example:
import { compose } from 'redux'
import { connect } from 'react-redux'
// not released... yet
import { connectRouter } from '@zumper/react-router-connect'
import { withReducer } from '@zumper/redux-add-reducer'
// tae-dux
import { connectMy } from 'components/My/module'
// code-split reducer
import { reducer } from 'modules/myView'
// ...
export const MyContainer = compose(
connectRouter(mapRouteToProps),
withReducer(reducer),
connect(
mapStateToProps,
mapDispatchToProps
),
connectMy(mapMyStateToProps, mapMyDispatchToProps)
)(My)
The latest version of react-redux exposes hooks that can be used instead of connect
. Are they better? It depends.
We can keep the pattern of containers and use hooks instead of an HOC.
import React, { memo, useCallback } from 'react'
import { shallowEqual, useDispatch, useSelector } from 'react-redux'
import { appMounted } from 'modules/app/actions'
import { selectIsMobile } from 'modules/app/selectors'
import { App } from './App'
const createAppContainer = (Component) => {
const MemoComponent = memo(Component, shallowEqual)
const Container = (ownProps) => {
// mapStateToProps
const mobile = useSelector(selectIsMobile)
// mapDispatchToProps
const dispatch = useDispatch()
const onClick = useCallback(() => dispatch(appMounted()))
// mergeProps
const mergeProps = {
mobile,
onClick,
}
return <MemoComponent {...ownProps} {...mergeProps} />
}
return Container
}
export const AppContainer = createAppContainer(App)
It is unclear if this pattern is "better". The benefit is that it makes it much easier to compose multiple data sources together. The downside is that you lose some of the built-in performance that connect
provides. The syntax is a little noisier than composing HOC's together.
It's a matter of flexibility over ease of use.
Read more about how hooks compare with connect:
https://itnext.io/how-existing-redux-patterns-compare-to-the-new-redux-hooks-b56134c650d2
Using regional state makes sense for ephemeral state, such as a form or a fancy accordion. For whole views it's still preferred to keep our state in the global redux store. However, if we know that this "view module" is only used with a specific route, then we can code-split our module and inject the reducer only when the route is loaded.
Sound crazy? It's an old idea.
The technique is borrowed from the now deprecated react-redux-starter-kit.
The enhanced store provides a new store.addReducer(key, reducer)
method that makes it practical to code-split redux modules.
The key technology here it to leverage the little-known store.replacerReducer
method to enable "injecting" a new reducer.
import thunk from 'redux-thunk'
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'
import { reducerMap } from 'modules'
export const createAppStore = (preloadedState) => {
const middleware = [thunk]
const enhancer = compose(applyMiddleware(...middleware))
const rootReducer = combineReducers(reducerMap)
const store = createStore(rootReducer, preloadedState, enhancer)
// manually "enhance" the store
store.reducerMap = reducerMap
store.addReducer = (key, reducer) => {
if (Object.hasOwnProperty.call(store.reducerMap, key)) {
return
}
store.reducerMap[key] = reducer
store.replaceReducer(combineReducers(store.reducerMap))
}
store.removeReducer = (key) => {
if (!Object.hasOwnProperty.call(store.reducerMap, key)) {
return
}
delete store.reducerMap[key]
store.replaceReducer(combineReducers(store.reducerMap))
}
return store
}
import { useMemo } from 'react'
import { useStore } from 'react-redux'
export const useReduxReducer = (key, reducer, options = {}) => {
const { shouldRemoveOnCleanup } = options
const store = useStore()
const addReducer = useMemo(() => {
store.addReducer(key, reducer)
}, [key, reducer, store])
addReducer()
}
import React from 'react'
import { useReduxReducer } from './useReduxReducer'
export const withReducer = (key, reducer, options) => (WrappedComponent) => {
const WithReducer = (props) => {
useReduxReducer(key, reducer, options)
return <WrappedComponent {...props} />
}
return WithReducer
}
import { compose } from 'redux'
import { connect } from 'react-redux'
// not released... yet
import { withmodule } from '@zumper/redux-add-reducer'
// our code-split module
import { reducer, selectGreeting } from 'modules/myView'
const key = 'myView'
const MyView = ({ greeting }) => {
return <div>{greeting}</div>
}
const mapStateToProps = (state) => {
return {
greeting: selectGreeting(state),
}
}
const MyViewContainer = compose(
withReducer(key, reducer), // <-- add the reducer before connecting to it
connect(mapStateToProps)
)(MyView)