I’ve decided to do a couple of Simplest Working Example posts. This will cover Redux, a state management library for React. What that means in practical terms is that it lets different components in an application access and modify the same bit of state, like a counter.

Project Setup

First setup a project using pnpm and vite:

pnpm create vite counter-app --template react
cd counter-app
pnpm install
pnpm add @reduxjs/toolkit react-redux redux

Actions and Reducers

First, we’re creating a features/counter directory, and then creating actions.js and reducer.js inside of that.

// src/features/counter/actions.js
import { createAction } from '@reduxjs/toolkit';

export const increment = createAction('counter/increment');
export const decrement = createAction('counter/decrement');

// src/features/counter/reducer.js
import { createReducer } from '@reduxjs/toolkit';
import { increment, decrement } from './actions';

const initialState = {
  value: 0,
};

const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state) => {
      state.value += 1;
    })
    .addCase(decrement, (state) => {
      state.value -= 1;
    });
});

export default counterReducer;

If this looks too much like magic, with createAction, then you can rewrite it as,

// src/features/counter/actions.js
export const INCREMENT = 'counter/increment';
export const DECREMENT = 'counter/decrement';

export const increment = () => ({
  type: INCREMENT,
});

export const decrement = () => ({
  type: DECREMENT,
});

// src/features/counter/reducer.js
import { INCREMENT, DECREMENT } from './actions';

const initialState = {
  value: 0,
};

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { ...state, value: state.value + 1 };
    case DECREMENT:
      return { ...state, value: state.value - 1 };
    default:
      return state;
  }
}

export default counterReducer;

I kind of like the older, more verbose version.

The Store

The action and the reducer work together to update the state. The client code, different components of the application, dispatch actions, and then the reducer catches those dispatched actions and updates the state. The the state is updated in the client code using the useSelector hook. The state “lives” in the react redux store.

// src/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/reducer';

const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export default store;

The Application

This is the top level app that holds the UI elements that we’re interested in, pulls reactive data from the store, and dispatches actions to update the store.

// src/App.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './features/counter/actions';

function App() {
  const count = useSelector((state) => state.counter.count);
  const dispatch = useDispatch();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
    </div>
  );
}

export default App;

This is what is sourced in index.html. It references our top level App component, and it makes the store available to that App component via a higher level Provider component.

// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import { Provider } from 'react-redux';
import store from './store';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);