To give myself a break from devops-y things, I decided on a sideways jaunt to linearize and compactify redux space. I’m going to need proper frontend devs to avert their eyes and/or hold their noses throughout this post, lol…
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:
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).
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:
redux react-redux @reduxjs/toolkit
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. 💭
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
Redux store has to be global, so in the index.[ts|js]
file, or wherever the top-level
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>
...
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>
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:
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)
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
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;
configureStore
methodThe 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.
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.
(TODO - I’ll update in next few days)
Narrator Voice: she never did…