I am building a tool that requires a great deal of state management on the client side. After all of 30 seconds of research I decided to use Redux for state management. (For the record, after I started down this path someone mentioned Zustand as an alternative, which they claimed had an easier interface. I haven’t investigated it at all, but someone might find it useful to know of alternaitves).
I largely followed the Redux Typescript tutorial, but there were a couple of things that I ended up asking for help with, because it wasn’t clear (to me) in the tutorial.
The basic concepts that are required to be understood is that redux
has a store
that components can Create, Read, Update, Delete (CRUD
) items from.
In earlier releases a developer needed to understand that the store
was immutable, so changes were made by creating a copy, making changes to the copy, then saving the new version of the store
.
Redux-toolkit has abstracted that away, developers only need to tell redux
what changes are being made, and the immer library is used under the hood to keep with the immutable nature of the redux
store
. (This is helpful to know, however, when debugging, because you cannot just console.log(state)
).
Down to business, er, I mean, let’s look at how things are done in the code, with explanations.
First of all, access to the state
is via reducers.
First, I put this code in features/location/locationSlice.ts
import { createSlice } from '@reduxjs/toolkit';
// This import allows us to dump the state to console log
// for debugging
import { current } from "immer";
// This is the shape of the data being stored in redux.
// In this instance there is only one object, indexed by an id.
// But there is no reason that there couldn't be more objects.
// eg. If you wanted one for UI, and one for some
// other set of data.
interface EntityState {
x: number;
y: number;
};
}
// Initialise the state for redux.
const initialState: EntityState = {};
// This is where the work is done.
// We name the object (this becomes important when reading from
// the state in components).
// We define the reducers, note that the reducers can be defined
// elsewhere, and called here in the reducers section.
export const positionSlice = createSlice({
name: 'position',
initialState,
reducers: {
// This is a modifier reducer, modifying the current state.
moveItem: (state, action) => {
// decompose the action payload.
const { id, x, y } = action.payload;
// Update the nested object.
if (state[id]) {
state[id].x = x;
state[id].y = y;
}
},
// This is a Creation reducer. It's job is to create new
// instances of objects being stored in the state.
newItem: (state, action) => {
// This is a debug statement, purely for demonstration.
console.log(current(state));
// Decompose the action payload.
const { id, x, y, width, height, elementType } = action.payload;
// Create the new nested object.
// Note that I am not doing any checking if an object with
// the same name already exists.
// I am using uuids for the name and am confident that
// there will be no collisions, but defensive code would
// be a good idea here.
state[id] = {
x: x,
y: y,
}
},
},
});
// Expose the actions to the world.
export const { newItem, moveItem } = positionSlice.actions;
export default positionSlice.reducer;
I have a store
, defined in store.ts
, it’s not very complex
import { configureStore } from '@reduxjs/toolkit'
// When we build the store we use the previously created
// reducer.
import positionReducer from './features/position/positionSlice';
const store = configureStore({
reducer: {
position: positionReducer,
},
})
// Make the RootState and Dispatch types easier to access
// in components.
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export default store
edit: I forgot to add the following index.tsx
(or App.tsx
- this is purely to make the store available to all of the components.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import store from './store'
import { Provider } from 'react-redux'
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
That’s all that’s required for redux
. The next parts are using the redux
store
in components.
This shows the creation reducer being called.
import React, { useState } from "react";
// import the creation reducer
import { newItem } from "../features/position/positionSlice";
// Import useDispatch - this is used to call the reducer.
import { useDispatch } from 'react-redux'
const { v4: uuidv4 } = require('uuid');
const Editor = () => {
// make the function a little bit nicer to call.
const dispatch = useDispatch();
// add an element
const handleClick = (name: string, height: number, width: number) => {
// Set the ID for the new item.
const ID = uuidv4();
// Add new item to global state.
dispatch(newItem({
id: ID,
x: 0,
y: 0,
}));
};
const handleItemClick = () => { };
return <div className="example">
<span className="symbol" id="projectStarter" onClick={() => handleClick("project", 120, 120)}>
[Product/System]
</span>
</div>
};
export default Example;
The modifier access is exactly the same, but the read access is a little more tricky, so that’s what i will show here.
import React from "react";
// Import the tools needed to access the redux state.
import { useSelector } from 'react-redux';
// This was our way of making it easier to get the type of the state.
import { RootState } from "../store";
type ArrowProps = {
startPoint: ElementItem;
endPoint: ElementItem;
};
const Arrow = ({ startPoint, endPoint }: ArrowProps) => {
// Get start and end points from global state.
// It's important to note the use of the name from the first file to access the nested object inside the state.
// That is, the state itself is a complex object, and our data has to be accessed as a nested object.
const start = useSelector((state: RootState) => state.position[startPoint.id]);
const end = useSelector((state: RootState) => state.position[endPoint.id])
...
};
export default Arrow;
I have heavily commented the code because I feel that the explanation of what’s happening is best read in place, where something is being used.
It should be clear, though, that redux
is very simple to use, when you know what you are doing ;)