Simple Redux
A bare-bones redux and react-redux implementation for teaching purposes
Table of contents
- Why
- Who's this for?
- What's included?
- The store
- combineReducers
- Connecting a store to React
- Memoization
- JavaScript values
- Optimizing the Connect component
- Optimizing your containers
Why
Simple Redux is intended to teach you the core concepts of Redux.
Who's this for?
This is for developers with experience using Redux with React.
You won't learn how to use actions, reducers, or the connect
function. Instead, you'll learn how they work under the hood.
What's included?
Simple Redux implements code from the redux
and react-redux
packages. This includes createStore
, combineReducers
, connect
, and the Provider
component.
The file and function names are purposefully close to the original source code. If you decide to read the Redux code after reading through this example, you'll be in familiar territory.
This README is a walk-through of the Simple Redux code. If you're a maverick that doesn't care for structure, you can dive straight into the source code in the redux
and react-redux
directories.
The first code to look at in the walk-through is the code that creates a store.
The store
In Redux you create a store by calling createStore
with a reducer:
import { createStore } from 'redux'
import reducer from './reducer'
const store = createStore(reducer)
createStore
forms a closure which contains the store state (currentState
), and returns the store methods:
// redux/createStore.js
export default function createStore(reducer) {
let currentState
let listeners = []
const getState = () => currentState
const subscribe = listener => {
listeners.push(listener)
}
const dispatch = action => {
currentState = reducer(currentState, action)
listeners.forEach(l => l())
}
// ..
return {
dispatch,
subscribe,
getState
}
}
You can see that dispatch
calls the store reducer with an action and the previous state to generate new state:
// redux/createStore.js
export default function createStore(reducer) {
// ..
const dispatch = action => {
currentState = reducer(currentState, action)
// ..
}
// ..
}
That's why a reducer must always return an object. As a reminder, this is what a reducer looks like:
// app/store/todosReducer.js
const initialState = {
items: []
}
export default function todosReducer(state = initialState, action) {
if (action.type === 'ADD_TODO') {
return {
...state,
items: [...state.items, action.payload]
}
}
return state
}
Notice that the reducer state
param has a default value of initialState
. This value is used when the state is initialized:
// app/store/todosReducer.js
const initialState = {
items: []
}
export default function todosReducer(state = initialState, action) {
// ..
return state
}
The state is initialized in createStore
by dispatching an action with a unique type
that (hopefully) won't match any cases in the reducer.
// redux/createStore.js
export default function createStore(reducer) {
let currentState
const dispatch = action => {
currentState = reducer(currentState, action)
// ..
}
dispatch({ type: '@@redux/INIT$' })
return {
// ..
}
}
In this initial dispatch
call currentState
is undefined
. So when the reducer is called by dispatch
with undefined
as the first argument, it will return the default initialState
value to create the currentState
:
// app/store/todosReducer.js
const initialState = {
items: []
}
export default function todosReducer(state = initialState, action) {
// ..
return state
}
That's all there is to the store—it's surprisingly simple! But you probably use more than one reducer to create your store. That's where combineReducers
comes in.
combineReducers
Instead of using a single root reducer, most apps use multiple reducers by using the combineReducers
helper function:
// app/store/reducers.js
import { combineReducers } from 'redux'
import todos from './todosReducer'
import modal from './modalReducer'
const rootReducer = combineReducers({
todos,
modal
})
Which generates a single state object:
store.getState() // =>
// { todos: { items: [] }, modal: { } }
combineReducers
is pleasingly simple. It returns a function (combination
) that calls each reducer with the previous state generated by the reducer, and the action:
// redux/combineReducers.js
export default function combineReducers(reducers) {
return function combination(state = {}, action) {
let nextState = {}
Object.keys(reducers).forEach(key => {
let reducer = reducers[key]
let previousStateForKey = state[key]
nextState[key] = reducer(previousStateForKey, action)
})
return nextState
}
}
That's all the code needed to create a Simple Redux store.
The next part to look at is the code that connects the store to an app.
Connecting a store to React
React projects use the react-redux
package to connect a store to a React app.
You do this by creating container components with the connect
function:
import { connect } from 'react-redux'
const ModalContainer = connect(mapStateToProps)(Modal)
In order for a container to work you need to render a <Provider />
component somewhere in the component tree above the container.
A <Provider />
component provides the store, which it receives as a prop:
import store from './store/store'
const App = () => (
<Provider store={store}>
<ModalContainer />
</Provider>
)
export default App
<Provider />
makes the store available to child components using the React Context API:
// react-redux/Provider.js
import React, { Component } from 'React'
import Context from './Context'
export default class Provider extends Component {
constructor(props) {
super(props)
this.state = {
storeState: props.store.getState()
}
}
// ..
render() {
return (
<Context.Provider value={this.state}>
{this.props.children}
</Context.Provider>
)
}
}
Note: Read the React docs if you aren't familiar with the context API
A child component can access the <Provider />
component's state, which includes the storeState
value, by rendering a Context.Consumer
:
const ModalStatus = () => (
<Context.Consumer>
{({ storeState }) => <p>Modal is: {storeState.modal.visible}</p>}
</Context.Consumer>
)
With this implementation the storeState
value is static. To make sure the storeState
is up to date, the <Provider />
component needs to re-render when the store state updates.
When a <Provider />
component mounts, it subscribes to the store using the store subscribe
method. Remember subscribe
adds a listener (a callback function) to the store listeners
array. Each listener is called after the new store state is calculated during dispatch
:
// redux/createStore.js
export default function createStore(reducer) {
// ..
let listeners = []
// ..
const subscribe = listener => {
listeners.push(listener)
}
const dispatch = action => {
// ..
listeners.forEach(l => l())
}
// ..
}
The listener callback calls this.setState
with the new store state:
// react-redux/Provider.js
export default class Provider extends Component {
// ..
componentDidMount() {
const store = this.props.store
store.subscribe(() => {
this.setState({
storeState: store.getState()
})
})
}
// ..
}
setState
then triggers a component re-render, which in turn re-renders each of the <Provider />
child components (unless you code against it using componentShouldUpdate
or some other method). So each of the child components of <Provider />
is re-rendered.
This is where connect
comes in. Remember, connect
creates a container component that maps the store state to the props of a wrapped component. Something like this:
const mapStateToProps = state => ({ visible: state.modal.visible })
export default connect(mapStateToProps)(Modal)
connect
returns a <Connect />
component that renders its wrapped component. <Connect />
then generates props for the wrapped component by calling mapStateToProps
with the store state available in the <Context.Consumer />
child function.
An inefficient implementation would look like this:
import React from 'react'
import Context from './Context'
export default function connectHOC(mapStateToProps) {
return function wrapWithConnect(WrappedComponent) {
const Connect = () => {
const renderWrappedComponent = ({ storeState }) => {
const props = mapStateToProps(storeState)
return <WrappedComponent {...props} />
}
return <Context.Consumer>{renderWrappedComponent}</Context.Consumer>
}
return Connect
}
}
With this implementation the <Connect />
component re-renders each time the store updates. In a large app this could be a big performance hit.
Redux improves on this simple implementation by using a caching technique known as memoization.
Memoization
Memoization (pronounced mem-oh-i-zay-shun) is a fancy word for caching the results of a function call.
Memoization is used to avoid recomputing expensive function calls, like rendering a React component tree.
One implementation of memoization works by saving the result and arguments of the last function call. If the next function call has the same argument as the previous call, the cached result is returned. If the argument is different, the function computes and caches a new result:
const memoizeFn = fn => {
let prevResult
let prevArg
return arg => {
if (prevArg === arg) {
return prevResult
}
const result = fn(arg)
prevArg = arg
prevResult = result
return result
}
}
const calculateResult = a => a * 2
const memoizedFn = memoizeFn(calculateResult)
memoizedFn(1) // calls `calculateResult`
memoizedFn(1) // returns cached arg
memoizedFn(1) // returns cached arg
memoizedFn(2) // calls `calculateResult`
Memoization only works for functions when the calculation function is deterministic. That is, given the same input it always returns the same result. For example, a sum
function that returns the sum of its arguments is deterministic:
const sum = (a, b) => a + b
An example of a non-deterministic function is a timeAgo
function that uses Date.now
to calculate the current time. The function would return a different result each time it's called:
const timeAgo = t => Date.now() - t
Another requirement for memoization is that the function must be free from side effects. Side effects are changes to an application state outside of the function. For example, a setTitle
function that sets the document.title
has the side effect of changing the value of document.title
:
const setTitle = title => {
document.title = title
}
A deterministic function that doesn't produce any side-effects is known as a pure function. Pure functions are vital for memoization to work, which is why redux tutorials are so strict about keeping your reducers pure.
Most memoization implementations use equality checks to ensure arguments haven't changed. So to grok memoization in Redux, you need to understand how JavaScript compares values.
JavaScript values
As of 2020, there are seven primitive JavaScript types: Undefined, Null, Boolean, Number, BigInt, Symbol, and String.
When you compare two values using the strict equality operator (===
), the JS engine produces either true or false. The way the JS engine determines equality depends on the type of the values being compared.
Two different strings are said to equal each other if they have the exact same sequence of code units:
const str = 'some string'
str === str // true
str === 'some string' // true
str === 'different string' // false
Two numbers are said to equal each other if they contain the same number value:
const num = 1
num === num // 1
num === 1 // true
num === 2 // false
The other data type in JavaScript is Object. Everything that isn't a primitive type in JavaScript has type Object, including functions (callable objects) and arrays.
An Object has a unique object value that is separate from its shape:
const obj1 = { prop1: true } // has unique object value
const obj2 = { prop1: true } // has unique object value
When you compare two Object types using the strict equality operator (===
), the JS engine compares the object values, rather than the shapes:
obj1 === obj1 // true
obj2 === obj2 // true
obj1 === obj2 // false
Note: Symbols work in a similar way to Objects during equality checks.
Now that you understand memoization and JavaScript values, you can see how redux uses it to avoid unnecessary re-renders.
Optimizing the Connect component
By now you have a good overview of Redux, but the devil is in the details. The complexity of the Redux source code comes from the optimizations used to avoid re-rendering.
By the end of this section you'll understand how the optimizations work, which will make it easier to keep your own code optimized.
Remember, in react-redux
the connect
function creates a <Connect />
component that renders a wrapped component using props derived from mapStateToProps
.
The naive implementation renders a new component each time <Connect />
updates:
import React from 'react'
import Context from './Context'
export default function connectHOC(mapStateToProps) {
return function wrapWithConnect(WrappedComponent) {
const Connect = () => {
const renderWrappedComponent = ({ storeState }) => {
let props = mapStateToProps(storeState)
return <WrappedComponent {...props} />
}
return <Context.Consumer>{renderWrappedComponent}</Context.Consumer>
}
return Connect
}
}
With the naive implementation the wrapped component (and all its child components) will re-render each time dispatch
is called. Not good.
The solution is to stop the wrapped component from re-rendering if the props object created by mapStateToProps
hasn't changed since the last render.
Redux can't use a strict equality (===
) check to make sure the new props are the same as the previous props, because mapStateToProps
returns a new object (with a new object value) each time it's called.
Instead, redux uses a shallowEqual
helper function. shallowEqual
loops through each property and asserts that it strict equals the previous property:
// react-redux/shallow-equal.js
export default function shallowEqual(objA, objB) {
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)
for (let i = 0; i < keysA.length; i++) {
if (objA[keysA[i]] !== objB[keysA[i]]) {
return false
}
}
return true
}
So when a <Connect />
component re-renders, it can call mapStateToProps
with the new state, and use shallowEqual
to compare the previous props with the newly generated props:
const nextProps = mapStateToProps(state)
const propsChanged = !shallowEqual(lastDerivedProps, nextProps)
To perform this calculation, Redux needs a reference to the previous props. Redux uses memoization to do this:
function makeDerivedPropsSelector(mapStateToProps) {
let lastDerivedProps
return function selectDerivedProps(state) {
lastState = state
const nextProps = mapStateToProps(state)
const propsChanged = !shallowEqual(lastDerivedProps, nextProps)
if (propsChanged) {
lastDerivedProps = nextProps
}
return lastDerivedProps
}
}
The selectDerivedProps
selector is created in <Connect />
's constructor function:
Note: A selector is a function that uses memoization. You see this terminology a lot in the redux ecosystem.
export default function connect(mapStateToProps) {
return function wrapWithConnect(WrappedComponent) {
class Connect extends PureComponent {
constructor(props) {
super(props)
this.selectDerivedProps = makeDerivedPropsSelector(mapStateToProps)
}
// ..
}
return Connect
}
}
selectDerivedProps
returns the previous props object if the previous props shallow equal the new props:
function makeDerivedPropsSelector(mapStateToProps) {
let lastDerivedProps
return function selectDerivedProps(state) {
// ..
if (propsChanged) {
lastDerivedProps = nextProps
}
return lastDerivedProps
}
}
So <Connect />
can use the strict equality operator to check if the props changed:
let derivedProps = this.selectDerivedProps(storeState)
if (derivedProps !== previousProps) {
// do something
}
If the component props have changed, <Connect />
should update the wrapped component. If the props have not changed, <Connect />
should not update the component.
One way to avoid re-rendering a child in React is to return the same element that was used in the previous render.
<Connect />
uses memoization again to return the previously generated rendered element if the component does not need to update. This is done with another selector, selectChildElement
. If the props have changed, the selector will re-render <WrapperComponent />
:
function makeChildElementSelector(WrappedComponent) {
let lastChildProps
let lastChildElement
return function selectChildElement(childProps) {
if (childProps !== lastChildProps) {
lastChildProps = childProps
lastChildElement = <WrappedComponent {...childProps} />
}
return lastChildElement
}
}
So now the full <Connect/>
component using selectors will calculate new props using the makeDerivedProps
selector. It then calls selectChildElement
with the new props. If the props have not changed, the selector will return the previously rendered element, and the wrapped component will not re-render. In full it looks like this:
// react-redux/connect.js
export default function connectHOC(mapStateToProps) {
return function wrapWithConnect(WrappedComponent) {
class Connect extends PureComponent {
constructor(props) {
super(props)
this.selectDerivedProps = makeDerivedPropsSelector(mapStateToProps)
this.selectChildElement = makeChildElementSelector(WrappedComponent)
this.renderWrappedComponent = this.renderWrappedComponent.bind(this)
}
renderWrappedComponent({ storeState }) {
let derivedProps = this.selectDerivedProps(storeState)
return this.selectChildElement(derivedProps)
}
render() {
return (
<Context.Consumer>{this.renderWrappedComponent}</Context.Consumer>
)
}
}
return Connect
}
}
Note: The Simple Redux source code includes extra checks to avoid re-renders. Check out the code
In the original react-redux
code, the makeDerivedProps
selector uses mapStateToProps
, mapDispatchToProps
, mergeProps
, and the own props of the <Connect />
component to compute the props. But it works on the same principle: if the result of generating the new component props shallow equals the previous props, then the previous props object is returned. Which means the selectChildElement
function can return the previously rendered component if childProps === lastChildProps
.
Optimizing your containers
The main takeaway from this is that Redux re-renders based on strict equality checks.
If your mapStateToProps
function returns a new object each time it's computed, the shallow check will fail and the component will re-render each time dispatch
is called:
const mapStateToProps = state => ({
childProps: {
name: state.modal.name
}
})
Instead, use primitive values in mapStateToProps
:
const mapStateToProps = state => ({
childName: state.modal.name
})
Alternatively, you can use a selector library, like Reselect, to create memoized functions that always return the same object if the values it depends on have not changed:
const getName = state => state.modal.name
const childPropsSelector = createSelector(
[getName],
name => ({ name })
)
const mapStateToProps = state => ({ childProps: childPropsSelector(state) })
Congratulations, you've reached the end of the Simple Redux walk-through. At this point you should have a good understanding of how Redux works under-the-hood.
Now go forth and avoid unnecessary re-renders in your app!