Designing my TypeScript useReducer
Last updated: Feb 01, 2021 • 8 min read
In this article we’ll go over how I design my application state in a TypeScript & React app using useReducer hook & Context API. I use this technique across my app for maintaining the application state.
Why useReducer?
State-management libraries like Redux/MobX still hold their pros/cons for maintaing application state, but react state owns its position.
Since the introduction of hooks, I’ve primarily used react state
for state management, and when clubbed with Context API,
it starts behaving like all-purpose-flour
.
React state is pretty lightweight, needs no extra libraries & can be quickly setup for smaller lightweight projects.
But before we jump into this, lets do a quick recap.
Recap useReducer
useReducer
is the preferred hook you’d want to use when dealing with
complex state.
useReducer
uses a reducer function, along with initialState to define the
initial “reducer”, which then returns you the current state value and
a special dispatch function.
This dispatch function can be used to “dispatch” actions to the reducer
which can be handled with a simple switch statement (just like redux
does) &
the state then would have the updated values. The reference to the dispatch function
stays the same between FC (Functional Component) renders and can be directly passed down as props.
Let’s look at a quick example, taken from React’s documentation:
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
</>
);
}
In this example above, we see dispatch is sent special “instructions” like
increment
and decrement
to update the reducer state values. One can construct
special instructions here to update the state as desired.
Hopefully that summarizes useReducer
. If not, do head over to
React’s documentation.
Pro-tip
useState
uses useReducer
under the hood! Let’s see how.
const myreducer = (_, action) => {
return action.state;
};
const [state, dispatch] = React.useReducer(myreducer, true);
const setState = flag => dispatch({ state: flag });
setState(false);
setState(true);
// is equaivalent to
const [state, setState] = useState(true);
setState(false);
setState(true);
So if one were to modify the dispatch function to just take the state
you want the reducer to set, you’d get the useState
behavior right away!
Recap useContext
The React Context API helps developers truely have “global” react state that can be accessed without props being passed to children and grandchildren, AKA prop-drilling, and helps skip re-rendering components which don’t use that context-state - ultimately leading to dynamic UI and cleaner code.
The useContext
hook is an alternative of Context.Consumer
API, allowing
developers to consume values under a Provider.
type Theme = "light" | "dark";
interface ThemeContextState {
theme?: Theme;
setTheme?: Function;
}
const ThemeContext = React.createContext<ThemeContextState>({});
export const UseContextApp = () => {
return (
<ThemeWrapperApp>
<Radios />
<Status />
<Card title="Child #1" size="large" enableRenderCount={true}>
<Card title="Child #2" enableRenderCount={true}>
<ChildWithContext />
</Card>
</Card>
</ThemeWrapperApp>
);
};
const Radios = () => {...}
const Status = () => {
const { theme } = React.useContext(ThemeContext);
return <div className="status-bar">VALUE: {String(theme)}</div>;
};
const ThemeWrapperApp = ({ children }: React.PropsWithChildren<{}>) => {
const [theme, setTheme] = React.useState<Theme>("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
const ChildWithContext = () => {
const { theme } = React.useContext(ThemeContext);
return (
<>
<span>{theme}</span>
<Card title="Child-N" size="mini" enableRenderCount={true} />
</>
);
};
The above example looks like the gif shown here. Each child card has a number at the top right corner, indicating how many times it was rendered.
We start by creating the context at L8, and using the Provider API to wrap all its
children like L31. The Status component consumes the context and updates the same.
At L17 the ChildWithContext
is a Child which is two level’s from the top-context.
In the GIF we see all the Child cards are skipped from rendering when the radio buttons actually change the value from the parent, except the one’s consuming it.
That’s React Context!
Counter
Now its time to merge useReducer
and useContext
! Let’s try this with Counter
example first. But before we jump into the example, lets look at the core
file structure and follow that with the contents of the files.
File Structure
I like to keep my files co-located, so I try and keep them in a single folder, at least the ones which are related. I’ve tried another way as well, i.e. keeping hooks, actions, styles, etc. together. There’s pros and cons to both these approaches IMO, but lets stick with single folder approach for this one.
src
├── App.tsx
└── Counter
│ ├── Counter.tsx
│ ├── actions.ts
│ ├── index.ts
│ ├── interface.ts
│ ├── reducer.ts
│ └── useCounterState.tsx
└── index.tsx
High-level overview for the files:
interface.ts
: holds the interfaces across the entire Counter examplereducer.ts
: defines the reducer itself, along w/ initial state etcactions.ts
: functions which use the action interface & can be used in React FCsCounter.tsx
: the real React FC, which will use the actions, state, etcuseCounterState.tsx
: is the hook where the reducer and context API are fused together
Interfaces
For this quick example, the state will only capture the current value which
will be called CounterState
.
There will be only two kinds of actions viz. Increment and Decrement.
We’ll create a Enum for that, let’s call it CounterStateEnum
. This will behave
like a typescript discriminator
for reducer switch case - the case will switch on action.type
but we’ll get to
that in the reducer section.
To enable that we create a generic union type called CounterAction
- which
will be a combination of CounterIncrementAction
and CounterDecrementAction
.
export interface CounterState {
value: number;
}
export enum CounterStateEnum {
COUNTER_STATE_INCREMENT = "COUNTER_STATE_INCREMENT",
COUNTER_STATE_DECREMENT = "COUNTER_STATE_DECREMENT",
}
export interface CounterIncrementAction {
type: CounterStateEnum.COUNTER_STATE_INCREMENT;
}
export interface CounterDecrementAction {
type: CounterStateEnum.COUNTER_STATE_DECREMENT;
}
export type CounterAction = CounterIncrementAction | CounterDecrementAction;
export type CounterDispatch = React.Dispatch<CounterAction>;
Actions
Moving on to action creators, as some may call them. These will be functions which we can directly invoke from the components themselves.
We’ll define two viz. counterIncrement
and counterDecrement
and
incrementing/decrementing respectively.
export const counterIncrement = (): CounterIncrementAction => {
return {
type: CounterStateEnum.COUNTER_STATE_INCREMENT,
};
};
export const counterDecrement = (): CounterDecrementAction => {
return {
type: CounterStateEnum.COUNTER_STATE_DECREMENT,
};
};
Reducer
The core-functionality of your hook will be defined here. For the counter example, it might seem very simple use-case, the same technique can be extended further handle more complex states. Let’s take a look at the implementation now.
export const initialCounterState: CounterState = {
value: 0,
};
export const reducer = (state: CounterState, action: CounterAction) => {
switch (action.type) {
case CounterStateEnum.COUNTER_STATE_INCREMENT:
return {
...state,
value: state.value + 1,
};
case CounterStateEnum.COUNTER_STATE_DECREMENT:
return {
...state,
value: state.value - 1,
};
default:
throw new Error(`Unhandled action type`);
}
};
useReducerState
We’ll use two context instances here:
CounterDispatchContext
: for storing the dispatch functionCounterStateContext
: for consuming the state
Splitting this into two sections, helps one prevent re-rendering components which dispatch an update in state but don’t really consume the state itself.
const CounterStateContext = React.createContext<CounterState>(
initialCounterState
);
const CounterDispatchContext = React.createContext<CounterDispatch>(
() => undefined
);
We can define corresponding hooks to take advantage of the useContext
hooks
and even define a hook to extract both these variables together!
export const useCounterState = (): CounterState => {
return React.useContext(CounterStateContext);
};
export const useCounterDispatch = (): CounterDispatch => {
return React.useContext(CounterDispatchContext);
};
export const useCounterContext = () => {
const state = useCounterState();
const dispatch = useCounterDispatch();
return {
state,
dispatch,
};
};
We also define a Provider CounterProvider
which sprinkles and allows children
variables to consume state/dispatch along with initializing useReducer
. This
CounterProvider
can be used for consuming both these context instances defined
allow for a cleaner import.
interface CounterProviderProps {
children: React.ReactChild | React.ReactChildren;
}
export const CounterProvider = (props: CounterProviderProps) => {
const [state, dispatch] = React.useReducer(reducer, initialCounterState);
return (
<CounterDispatchContext.Provider value={dispatch}>
<CounterStateContext.Provider value={state}>
{props.children}
</CounterStateContext.Provider>
</CounterDispatchContext.Provider>
);
};
This above is how the CounterProvider
looks like.
index.ts
An index file to export everything.
export * from "./useCounterState";
export * from "./interface";
export * from "./actions";
export * from "./reducer";
...and more
Counter
The simple FC, consuming the state & using the dispatch functions
import {
counterDecrement,
counterIncrement,
useCounterState,
useCounterDispatch,
} from "./";
export const Counter = () => {
const dispatch = useCounterDispatch();
const { value } = useCounterState();
const increment = () => dispatch(counterIncrement());
const decrement = () => dispatch(counterDecrement());
return (
<div className="counter-container">
<h1>{value}</h1>
<div className="actions">
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
</div>
);
};
App
How would you consume this inside an App?
import { CounterProvider, Counter } from "./Counter";
export default function App() {
return (
<CounterProvider>
<div className="App">
<Counter />
</div>
</CounterProvider>
);
}
Here’s a codepen putting all of it together. https://codesandbox.io/s/my-context-0k8dd