Enterprise application architecture with redux (Time machine attached!)
The difference between simple software and complex software need not be that the simple one has simple components and the complex has complex components with a complicated mesh of controls flowing around.
The complex software can be a huge number of simple components communicating in a disciplined control flow.
Redux helps us implement that discipline across our project.
Resources
Clone the start branch to follow the tutorial. You can also have the final branch for reference.
// Clone start branch:
git clone -b 02-redux-start github.com/karthickthankyou/epic-clone// Clone final branch:
git clone -b 02-redux-final github.com/karthickthankyou/epic-clone
The two things we should get right
“We believe that the major contributor to this complexity in many systems is the handling of state and the burden that this adds when trying to analyse and reason about the system. Other closely related contributors are code volume, and explicit concern with the flow of control through the system.” — Out of the Tarpit
- Handling of state
- Flow of control
The Uni-Directional Mental model
The UI will do two things.
- Listen to selected data from the store.
- Dispatch actions.
The above picture shows how the data is flowing in an unidirectional fashion. The components listen to a part of the state using selectors. And when actions happen from the component, we raise them as actions.
On repeatedly implementing this flow of control through out our application, we will end up with a clean architecture which makes plugging in new components and modifying existing components easy. That is the best thing we can ask for in a software development project.
It does not have to be move fast break things. It must be actually the opposite. We can move fast if our system makes change and new additions easy.
Modularity
Any project must allow adding, removing, updating features simple.
Look at the uni-directional picture above. There are few pages and components listening to particular parts of the state. They also dispatch actions when needed.
This unidirectional approach, reduces unwanted spagatti dependencies and allows us to do the below easier.
- Adding new components: If the component needs one of the existing piece of state and actions, we will simply use them and be done with it.If not, we will setup the infrastructure first and plug the selectors and actions to the component. And that state infrastructure we created is reusable for any component in the project.
- Update components: A change can be simply listening to another part of the state. If not, we will establish the underlying infrastructure and plug the selectors and actions.
- Delete components: When we decide to delete some zombie components and its state dependencies, our typescript will let us know what to delete and what is being accessed by someones else. That will be a compiler error. So, cleaning up will become a breeze.
Getting familiar with redux
I encourage you to try out the CRA template of the redux one.
npx create-react-app my-app --template redux-typescript
This will create a brand new project with a demo page to play around with redux concepts. I highly encourage you to playaround and break things.
We will be using the below scripts that helps us with some boilerplate code and types.
Installation
Clone the redux-start branch and install redux-toolkit
npm install @reduxjs/toolkit react-redux
Boilerplate
Copy paste the app/store.ts and app/hooks.ts from the CRA redux template into our cloned repository.
Or you can copy paste from below.
//store/index.tsimport { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'export const store = configureStore({
reducer: {},
})export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
//store/hooks.tsimport { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from '.';// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Note the reducer property inside configureStore. That is one part we will keep updating as we create new slices of data. In other lines, a store is being configured and it also exports a bunch of types we would need in our application.
First slice of state
Redux calls a piece of state a slice. It is relatable too like a slice of cake. This slice we create is a part of the store.
Create a file store/slices/userSlice.ts
The ES7 React/Redux/GraphQL/React-Native snippets VSCode extension helps us with redux boilerplate. Install the extension.
Type rx and control+space to get the intellisense to bring our rxslice snippet.
That will populate the file with the below code with the provision to add initialState, reducers, export actions etc.
import { createSlice } from '@reduxjs/toolkit'const initialState = {}const UserSlice = createSlice({
name: sliceName,
initialState,
reducers: { },
})export const {} = UserSlice.actions
export default UserSlice.reducer
IntialState: We are going to populate the uid and displayName from firebase. I also choose to have a loading, and error state in the slice itself so that the consuming components can react to the loading and error outcomes in their own way.
(incomplete snippets)const initialState = {
uid: null,
displayName: null,
loading: false,
error: null,
}
reducers: Our first reducer in this slice is setUser. We will dispatch this from inside our firebase auth listener.
The reducer function has access to the state and action. State is the slice’s state and not the whole store. The action parameter will carry the payload.
// (incomplete snippets)reducers: {
setUser: (state, action) => {
state.uid = action.payload?.uid
state.displayName = action.payload?.displayName
state.loading = false
state.error = null
},
}// And add the function to the exports as actions.export const { setUser } = userSlice.actions
As we try to assign the values directly to the state, You will get Assignment to property of function parameter ‘state’.eslintno-param-reassign error.
/* eslint-disable no-param-reassign */
Use the above comment in the top of the file. Is it safe to suppress the warning? Yes. Inside the redux slice, they make use of an amazing library called immerJS. Even the above code looks like an ugly mutation, immer is doing things in clean way that makes sure, we will always get back a new instance of state.
So, it is safe to suppress the no-param-reassign rule inside the redux slices.
Add the slice to the store
Import the default export UserSlice and attach that in the name user inside the reducer property.
// store/index.ts (incomplete snippets)
...reducer: {
user: UserSlice,
},
...
Redux DevTools extension
Install Redux DevTools extension following this link. This extension is a game changer and we will see why later.
Start the dev server and open localhost:3000.
npm start
In the chrome dev tools, you will be seeing the redux tab now. But right now, it should be saying, ‘No store found.’ Because we haven’t finished the redux set up yet.
In the root index.ts file, do these changes.
// index.ts (incomplete snippets)// import Provider from react-redux and our store
import { Provider } from 'react-redux'
import { store } from './store'// Wrap our App like this.
<Provider store={store}>
<App />
</Provider>
That’s it. Now, we will be seeing this.
Go to the state tab and there we have our user slice having our initial data.
The setup is almost done. Lets try dispatching the setUser action from App.tsx. Do the below changes
// Import setUser
import { useAppDispatch } from './store/hooks'
import { setUser } from './store/slices/UserSlice'
And dispatch the action like below.
const dispatch = useAppDispatch()const updateUser = () => {
dispatch(setUser({ uid: '123', displayName: 'Karthick' }))
}
What is the useAppDispatch? If we are not using typescript, we would have just used the useDispatch function from react-redux. This useAppDispatch carries the type AppDispatch which is exported from our store/index.ts typed with our state.
In simpler words, We use useAppDispatch() to get dispatch to dispatch the actions.
Also create a button and call the function updateUser.
<button type="button" onClick={updateUser}>
setUser
</button>
Redux in action
Click the button to see this happen in our redux dev tools.
The Diff tab (open by default) shows what exactly changed with the state. It could involve multiple slices. In this case, it is only the uid and displayName.
The left panel shows the action we dispatched. It is actually a timeline of actions being dispatched.
user/setUseruser: Slice
serUser: reducer
Cool, updated the store data by dispatching the action.
Selectors
Also, lets render the data in the UI using the selectors.
Import useAppSelector from hooks (A version of useSelector from react-redux typed with our store state).
The useAppSelector gets a function that has state as parameter. That state is the whole store we have and we can filter the state we need.
// App.tsximport { useAppDispatch, useAppSelector } from './store/hooks'const user = useAppSelector((state) => state.user)
We also get excellent intellisense to access the inner properties of the slice.
Clicking the button will dispatch an action to update the store. The components who select a slice from the store will get the updated data and hence get rerendered.
Selector cleanup
The below code to get the user slice does not look focussed to me. We let the component access the whole state to get what it needs like below.
const user = useAppSelector((state) => state.user)// Where. (state) => state.user function is called a selector.
Lets ask our slice to export the selector also. Lets create the below function in the userSlice.ts
export const getUser = (state: RootState) => state.user
and update the App.tsx like below.
import { getUser, setUser } from './store/slices/UserSlice'const user = useAppSelector(getUser)
Now, that gives a peace of mind.
Also, remember, this does not stop the developer from doing it the old way. We can probably write some custom eslint rules to stop that but. It is more of a good practice.
Also, the practice of always storing the selector from the slice, lets us write crazy selectors like below. And hence we can keep our component clean.
const combineWCPData = (input: Game[], wishlistIds: UserGame[], cartIds: UserGame[], purchasedIds: UserGame[]) => {const includes = (gameId: string) => ({
wishlisted: wishlistIds.some((game) => game.gameId === gameId),
inCart: cartIds.some((game) => game.gameId === gameId),
purchased: purchasedIds.some((game) => game.gameId === gameId)
})export const selectBrowseGamesWithWish = createSelector(
[
(state: RootState) => state.browseGames.games,
selectWishlistGameIds, // another selector
selectCartGameIds, // another selector
selectPurchasedGameIds, // another selector
],
combineWCPData
)
This is an actual selector we will write in this tutorial series.
Catch the culprit
We have seen a full cycle demonstrating the flow of control. I’d like to show a demo where one bugged component modifies the user data in the store in an unexpected way and how easy it is to catch that!
I created another component. I dispatch the same setUser action but with rubbish data. Here is the demo.
The “Set User” will set the right data and “Pollute the store” button messes up! Now, this is a contrived example. But imagine if this update happens from inside some custom hook where we fetched some data.
It does not matter where the culprit exists and when it messes up. We can catch the culprit using a time machine!
That’s it. How cool is that?
We can go back and forth in time to find exactly what piece of code messes up our state!
The magic is in the immutable state
The redux remembers a list of earlier versions of our store object. And it lets us apply different versions of store to see how the UI reacts to it. This helps us in navigating our application as if we go back and forth in time.
In react with redux, every visual of the application represents a snapshot of the data.
And as every change in the store is made through an action, we can exactly pinpoint which action created that change and who dispatched the action!
How cool is that?
You can access this final demo from the redux-final branch as given in the top. I recommend playing around by introducing more slices and components to mess up the state.
Once we get good at this, developing and debugging react-redux applications will become a breeze.
Thank you.