Skip to content

Instantly share code, notes, and snippets.

@iliaznk
Forked from heygrady/redux-module-patterns.md
Created April 13, 2020 09:15
Show Gist options
  • Save iliaznk/3f4a09b59d84b6cb5179cb987d184291 to your computer and use it in GitHub Desktop.
Save iliaznk/3f4a09b59d84b6cb5179cb987d184291 to your computer and use it in GitHub Desktop.
Redux module patterns: app state, view state and regional state

Redux module patterns

app state, view state and regional state

separating components and containers

and code-splitting reducers





Zumper loves redux.










  1. Regional state: The problem we're trying to solve
  2. React-redux, react hooks, react-redux hooks
  3. The best thing about redux: the boilerplate
  4. How to implement your own regional redux
  5. Bonus: Using redux modules
  6. Bonus: Using redux modules in react components
  7. Bonus: use hooks for containers
  8. Bonus: Code-splitting reducers









1. Regional state: The problem we're trying to solve

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:

  1. The Floorplan component is repeated.
  2. Each Floorplan state is distinct.
  3. No Floorplan state is shared with the broader app.
  4. Representing all floorplan state in global redux is complicated!

Key point: Each Floorplan has its own "regional" state.

module-react-redux

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 used useEnhancedReducer (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.










An actual picture of Tae

actual-picture-of-tae

Internally we refer to this technique as "taedux" because @xoddong (Tae) is the engineer that initially worked on this.










How to know if regional state is appropriate

  1. Regional state should only be relevant to a small component tree
  2. Regional state should never rely on SSR or preloading
  3. Regional state should usually derive only from user interactions
  4. 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.










2. React-redux, react hooks, react-redux hooks

React-redux is an efficient wrapper around react context. It exposes some standard interfaces for connecting a redux store to a react component.

What about hooks?

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).

Different types of state management

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.

A generic redux state shape

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.










3. The best thing about redux: the boilerplate

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)










4. How to implement your own regional redux

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

What are we doing?

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

MyRegionContext.js

// components/MyRegion/MyRegionContext.js

import { createContext } from 'react'

export const MyRegionContext = createContext()

MyRegionProvider.js

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>
}

MyRegion.js: Our component

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>
  )
}

Deep.js: First try; naiively using our regional store

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.










Using react-redux with our regional store

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)
}

connectMyRegion.js

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)

Deep.js: Second try; Using a custom regional container

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>
}

DeepContainer.js: Using a custom regional container

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)

MyRegion.js: final

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>
  )
}









5. Bonus: Using Redux Modules

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.

How to organize redux code

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.

Typical setup

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.

Ducks Modular Redux

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.

Redux Modules at Zumper

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.

What are we looking at?

  • 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

Marrying modules to state shape

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 located
  • initialLocationChanged: 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,
}

Splitting out sub-modules

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.

Example: actions

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)

Bonus: sub-modules

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/









6. Bonus: Using redux modules in react components

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/

Basic rules

  • Keep components focused on two things
    1. Rendering props
    2. Firing events
  • Keep containers focused on two things
    1. Selecting from state
    2. Dispatching actions

Components: render props and fire events

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>
}

Containers: connect a component to the app

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)

Connect all the things

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)









7. Bonus: use hooks for containers

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










8. Bonus: code-spliting view modules

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.

Enhance your store

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
}

Example: useReduxReducer

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()
}

Example: withReducer

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
}

Container: 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment