Skip to content

A bare-bones redux implementation for teaching purposes πŸŽ“

License

Notifications You must be signed in to change notification settings

eddyerburgh/simple-redux

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

9 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Simple Redux

A bare-bones redux and react-redux implementation for teaching purposes

Table of contents

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!

Further reading

About

A bare-bones redux implementation for teaching purposes πŸŽ“

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published