Redux Evolutions

Posted: 09 September, 2021 Category: rough notes Tagged: react-reduxreduxsidetracks

To give myself a break from devops-y things, I decided on a sideways jaunt to linearize and compactify redux space.

Overview

Redux is a library that allows an app to maintain a separate, global state, which is then accessible from any part of the app. It relies on more or less the following ontology:

  • a store, i.e. WHERE all the data representing the current state of the application is held
  • a bunch of actions that indicate WHAT changes can be made to global state
  • a bunch of reducers that determine HOW such state changes will occur

In React's implementation of redux, we have dedicated react hooks that prevent the app from getting its grubby mitts directly on the store. You can think of these hooks as WHO you can call, if you want to talk to the store. (What? I had to complete the terrible gimmick).

My redux redux

This strange little table is for those times when even I don't want to wade any further down this page.

Concept Shorthand
action <type[, payload]>
action creator (*) => action
reducer (<state, action>) => newState
slice / slice reducer feature::reducer
selector (<state>) => retrievedValue
store instantiation (1/2) store := createStore(<reducer [, initialState]>)
store instantiation (2/2) store := configureStore(<reducerMap>)
state change trigger store.dispatch(<action>)
get latest state store.getState()

And of course, the pre-requisite incantations:

  • yarn add | npm i redux react-redux @reduxjs/toolkit

Level 0: Proto-redux

Did you know that with just react by itself, and no other libraries, a humble:

import { useReducer } from "react"

unleashes the ability to start introducing a rather redux-y paradigm. witness:

  // somwhere on Earth, inside a component:
  const [things, dispatch] = useReducer((state, action) => {
    switch(action.type) { 
      ... 
      ...
      default: return state
    }
  }, []);

  const doFoo = (...) => {
    dispatch({
      type: "DO_THE_FOO",
      payload: {...}
    });
  };

  const doBar = (...) => {
    dispatch({
      type: "DO_THE_BAR",
      payload: {...}
    });
  };

I am not sure why you would do this though, instead of going full hog redux or relying entirely on things like state and react context. emoji-thought_balloon

Level 1: Vanilla redux

1.1 Create a store

This step amounts to creating the store's "root reducer". Eg at src/store/rootReducer.js:

const initialState = {
  ...
}

// A reducer receives <state, action>, and returns a new state.
function rootReducer(state=initialState, action) {
  switch (action) { ... }
}

export default rootReducer

So an example store with the usual example of a basic counter would be

export const initialState = {
  counter: 0
}

function rootReducer(state = initialState, action) {
  switch(action.type) {
    case 'INCREMENT' :
      return { counter: state.counter + 1 }
    // ... other cases ...
    default:
      return state
  }
}

export default rootReducer

1.2 Instantiate the store and make it available to the app

Redux store has to be global, so in the index.[ts|js] file, or wherever the top-level is being rendered, add:

import { createStore } from 'redux'
import { Provider } from 'react-redux'
import rootReducer from './store/rootReducer'
const store = createStore(rootReducer)
...
// wrap the app in the provider
<Provider store={store}>
	<App />
</Provider>
...

1.3 Use the store!

Inside any component file making use of the store, you would rely on the useSelector and useDispatch hooks (from the react-redux library), so that you don't manipulate the store directly:

import { useSelector, useDispatch } from 'react-redux'
...
// inside component:
  const dispatch = useDispatch()
  const myXYZ = useSelector((state) => state.xyz) // A selector! To get xyz from state!
  const increase = () => {
    dispatch({ type: 'INCREMENT'})
  }
...
// then in render:
<p>{myXYZ}<p>
<button onClick={increase}>increment</button>

Level 2: Separate reducers

The above soon grows unmanageable for complex stores, so you start separating your reducers out according to each core entity / concern in the store. Imagine a CMS that had to maintain content posts, as well as user accounts:

  • src/store/reducers/userReducer.js
  • src/store/reducers/blogPostReducer.js

Then, instead of the original original root reducer, you'd have to invoke redux's combineReducers method to stitch everything back together. I.e. you might create a src/store/reducers/index.js:

import { combineReducers } from 'redux'
import userReducer from './userReducer'
import blogPostReducer from './blogPostReducer'

const reducers = combineReducers({
  user: userReducer
  posts: blogPostReducer
})

export default reducers

Then, the instantiation of the store becomes const store = createStore(reducers)

Level 3: the Redux Toolkit & slice Reducers

Why stop there! Besides clumping together reducer functionality according to your featuresets, you're probably bored to death typing out actions and action creaters over and over again! Not to mention a bunch of other boilerplate. @reduxjs/toolkit to the rescue. Specifically

3.1 the createSlice method

... in which the feature-specific reducer is implemented as a slice, and bringing it into line with toolbox's opinionated conventions, as follows:

import { createSlice } from "@reduxjs/toolkit";

const postsSlice = createSlice({
  name: "things",
  initialState: {...},
  reducers: {
    foo: (state, action) => {
      // you can write EITHER state-immutable logic here, OR
      // fully state-mutating logic (thx to immer under the hood!) BUT NOT BOTH.
      ...
    },
    bar: (state, action) => {
      // ditto!
      ...
    }
  }
})

// nicely packaged actions waiting for u to use all over the app!
export const { foo, bar } = postsSlice.actions;

// and don't forget to extract and export the actual, final reducer!
export default postsSlice.reducer;

3.2 the configureStore method

The power of the above comes into play once you start aggregating all your carefully-designed reducers from all over the app, with toolkit's configureStore:

import { configureStore } from "@reduxjs/toolkit"

import thingsReducer from "./things/thingsSlice"
import stuffReducer from "./stuff/stuffSlice"
import whatnotReducer from "./whatnot/whatnotSlice"

// the spiffy new store!
export default configureStore({
  things: thingsReducer,
  stuff: stuffReducer,
  whatnot: whatnotReducer,
})

Nothing much changes elsewhere, but you can now import those feature-specific actions so that you don't have to type stuff like: dispatch({ type: 'DO_THE_THING', payload: 456 }) anymore. Now you can do things like dispatch(someAction(params)) because you exported all those nifty actions from the slice file, right? Right.

Level 4: Redux thunks

Thunks: I think of a thunk as an evaluation that can be postponed. I.e an evaluation that isn't made until it's needed. In redux specifically, it amounts to a curried function that takes the dispatch method so that it can be invoked later, when async tasks resolve:

export const incrementAsync = amount => dispatch => {
  setTimeout(() => {
    dispatch(incrementByAmount(amount))
  }, 1000)
}

or, broken down:

export const incrementAsync = amount => { // the outer fn is the "thunk creator"
 return async (dispatch, _getState) => {  // the inner fn
  await foo = fetchLiveAdjustment(amount)
  dispatch(incrementByAmount(amount * foo))
 }
}

redux-thunk is a plugin / middleware addition to stock redux which allows async reducers. When you use redux-toolkit, the configureStore() method as shown above already includes redux thunk, so your reducers are already thunkable.

Level 5: RTK Query

(TODO - I'll update in next few days)