Building a Fully Typed React State Module: Best Practices
A fully typed state module is the capstone of everything in this series: combining hooks, context, generics, and discriminated unions into a cohesive architecture that entire teams can rely on. When done well, it becomes the language your codebase speaks.
In my experience leading teams, the difference between apps that age gracefully and those that become monoliths often comes down to the quality of the state layer. This article synthesizes patterns into a production-ready module you can adapt.
Architecture Overview
A state module has three layers: types and contracts, a hook that provides state, and optionally a context for global access. Here's the structure:
// 1. Types layer: define all state shapes and actions
// 2. Hook layer: business logic (manage state, side effects, validation)
// 3. Context layer: distribute state across the app (optional, but recommended)
Let's build a concrete example: a shopping cart module.
Layer 1: Types and Contracts
Start by defining every type your module needs:
// types.ts
export interface CartItem {
id: string;
productId: string;
quantity: number;
price: number;
}
export interface Cart {
items: CartItem[];
createdAt: Date;
lastModified: Date;
}
export type CartAction =
| {
type: 'ADD_ITEM';
productId: string;
quantity: number;
price: number;
}
| {
type: 'REMOVE_ITEM';
itemId: string;
}
| {
type: 'UPDATE_QUANTITY';
itemId: string;
quantity: number;
}
| {
type: 'CLEAR_CART';
}
| {
type: 'LOAD_CART';
cart: Cart;
};
export interface CartContextValue {
cart: Cart;
dispatch: (action: CartAction) => void;
addItem: (productId: string, quantity: number, price: number) => void;
removeItem: (itemId: string) => void;
updateQuantity: (itemId: string, quantity: number) => void;
clearCart: () => void;
cartTotal: number;
itemCount: number;
}
export interface CartHookReturn {
cart: Cart;
addItem: (productId: string, quantity: number, price: number) => void;
removeItem: (itemId: string) => void;
updateQuantity: (itemId: string, quantity: number) => void;
clearCart: () => void;
cartTotal: number;
itemCount: number;
}
Separating types into a dedicated file makes them discoverable and reusable across your app.
Layer 2: Reducer and Hook
Implement the business logic in a hook:
// useCart.ts
import { Cart, CartAction, CartItem, CartHookReturn } from './types';
const cartReducer = (state: Cart, action: CartAction): Cart => {
switch (action.type) {
case 'ADD_ITEM': {
const existingItem = state.items.find((item) => item.productId === action.productId);
if (existingItem) {
return {
...state,
items: state.items.map((item) =>
item.productId === action.productId
? { ...item, quantity: item.quantity + action.quantity }
: item
),
lastModified: new Date(),
};
}
const newItem: CartItem = {
id: `${action.productId}-${Date.now()}`,
productId: action.productId,
quantity: action.quantity,
price: action.price,
};
return {
...state,
items: [...state.items, newItem],
lastModified: new Date(),
};
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter((item) => item.id !== action.itemId),
lastModified: new Date(),
};
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map((item) =>
item.id === action.itemId
? { ...item, quantity: Math.max(1, action.quantity) }
: item
),
lastModified: new Date(),
};
case 'CLEAR_CART':
return {
items: [],
createdAt: new Date(),
lastModified: new Date(),
};
case 'LOAD_CART':
return action.cart;
default:
const _exhaustive: never = action;
return _exhaustive;
}
};
export function useCart(): CartHookReturn {
const [cart, dispatch] = React.useReducer(cartReducer, {
items: [],
createdAt: new Date(),
lastModified: new Date(),
});
// Persist to localStorage on every change
React.useEffect(() => {
localStorage.setItem('cart', JSON.stringify(cart));
}, [cart]);
// Restore from localStorage on mount
React.useEffect(() => {
const saved = localStorage.getItem('cart');
if (saved) {
try {
const cart = JSON.parse(saved);
dispatch({ type: 'LOAD_CART', cart });
} catch (err) {
console.error('Failed to restore cart:', err);
}
}
}, []);
const addItem = React.useCallback(
(productId: string, quantity: number, price: number): void => {
dispatch({ type: 'ADD_ITEM', productId, quantity, price });
},
[]
);
const removeItem = React.useCallback((itemId: string): void => {
dispatch({ type: 'REMOVE_ITEM', itemId });
}, []);
const updateQuantity = React.useCallback((itemId: string, quantity: number): void => {
dispatch({ type: 'UPDATE_QUANTITY', itemId, quantity });
}, []);
const clearCart = React.useCallback((): void => {
dispatch({ type: 'CLEAR_CART' });
}, []);
const cartTotal = React.useMemo(
() => cart.items.reduce((total, item) => total + item.price * item.quantity, 0),
[cart.items]
);
const itemCount = React.useMemo(() => cart.items.length, [cart.items]);
return {
cart,
addItem,
removeItem,
updateQuantity,
clearCart,
cartTotal,
itemCount,
};
}
This hook combines useReducer, useEffect, useCallback, and useMemo. It handles persistence, derives computed values, and presents a clean API.
Layer 3: Context and Provider (Optional)
If your app is large, distribute the hook via Context:
// CartContext.ts
import { CartContextValue } from './types';
const CartContext = React.createContext<CartContextValue | undefined>(undefined);
export function CartProvider({ children }: { children: React.ReactNode }) {
const {
cart,
addItem,
removeItem,
updateQuantity,
clearCart,
cartTotal,
itemCount,
} = useCart();
const value: CartContextValue = React.useMemo(
() => ({
cart,
dispatch: () => {}, // Not directly exposed, but could be for advanced cases
addItem,
removeItem,
updateQuantity,
clearCart,
cartTotal,
itemCount,
}),
[cart, addItem, removeItem, updateQuantity, clearCart, cartTotal, itemCount]
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCartContext(): CartContextValue {
const context = React.useContext(CartContext);
if (!context) {
throw new Error('useCartContext must be used within CartProvider');
}
return context;
}
The Context is memoized to prevent unnecessary re-renders, and the custom hook enforces that the context is available.
Integration: Using the Module
With all layers in place, components are clean and type-safe:
// App.tsx
import { CartProvider } from './cart/CartContext';
export function App() {
return (
<CartProvider>
<HomePage />
</CartProvider>
);
}
// ProductCard.tsx
function ProductCard({ product }: { product: Product }) {
const { addItem } = useCartContext();
return (
<div>
<h2>{product.name}</h2>
<p>${product.price}</p>
<button onClick={() => addItem(product.id, 1, product.price)}>
Add to Cart
</button>
</div>
);
}
// CartSummary.tsx
function CartSummary() {
const { cart, cartTotal, itemCount, removeItem, updateQuantity } =
useCartContext();
return (
<div>
<h3>Cart ({itemCount} items)</h3>
<ul>
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.productId}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => updateQuantity(item.id, parseInt(e.currentTarget.value))}
/>
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
<p>Total: ${cartTotal.toFixed(2)}</p>
</div>
);
}
Every call is type-safe. TypeScript knows the shape of cart.items, the parameters to addItem, and the return types of computed values like cartTotal.
Testing the Module
A fully typed module is easier to test:
// useCart.test.ts
describe('useCart', () => {
it('adds items to the cart', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem('product-1', 2, 29.99);
});
expect(result.current.cart.items).toHaveLength(1);
expect(result.current.cart.items[0].quantity).toBe(2);
});
it('calculates cart total correctly', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem('product-1', 2, 10);
result.current.addItem('product-2', 1, 5);
});
expect(result.current.cartTotal).toBe(25);
});
it('removes items from the cart', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem('product-1', 1, 10);
});
const itemId = result.current.cart.items[0].id;
act(() => {
result.current.removeItem(itemId);
});
expect(result.current.cart.items).toHaveLength(0);
});
});
Tests are straightforward because the types guarantee correctness.
Best Practices Summary
- Separate concerns: types in one file, reducer/hook in another, context in another.
- Memoize Context values: prevent unnecessary re-renders with
useMemo. - Compute derived values: use
useMemofor totals, counts, filters that components need. - Validate at the boundary: check inputs in action dispatches, not in the reducer.
- Persist intelligently: use localStorage or sessionStorage with error handling.
- Export a custom hook:
useCartContextis more discoverable thanuseContext(CartContext). - Document invariants: add JSDoc comments explaining state contracts.
- Test early and often: typed modules are easier to test than untyped ones.
Key Takeaways
- Build state modules in three layers: types, hook (logic), and optional context (distribution).
- Discriminated unions in action types ensure all reducers are exhaustive.
- Memoize Context values to prevent re-renders of all consumers when internal state changes.
- Compute derived values like totals and counts in the hook, not in components.
- Use custom hooks (e.g.,
useCartContext) to enforce that the context is available. - Type everything: state, actions, dispatch parameters, return values.
Frequently Asked Questions
When should I use Context vs. just the hook?
Use the hook alone if state is component-local. Use Context if multiple sibling or distant components need access. A large app typically has 2-4 major Context modules (auth, user settings, notifications).
How do I avoid all consumers re-rendering when one part of Context changes?
Split Context by frequency of update. For example, auth state (rarely changes) and notifications (often changes) should be separate Contexts. Or use a library like Zustand or Redux.
Can I have multiple cart modules, each with its own state?
Yes. Create separate modules for each domain. A large app might have a CartModule, OrderModule, UserModule, etc., each with its own Provider.
How do I test the reducer without the hook?
Export the reducer function separately and test it with expect(reducer(state, action)) calls, passing in various actions and validating the output state.