State Management: Teaching Wooster to Remember Things
Frontend Adventures: Making Wooster Look Presentable
Before diving into Wooster's state management, I should share a story about how I finally "got" React. It was during a project I took on where I refactored someone else's React app - called Atomize Pro. It was facing what many React apps accumulate over time: dozens of useState hooks, useEffects with complex dependency arrays, and state scattered across components. The app worked, but it was becoming increasingly difficult to maintain.
You can read my full article covering my experience on that refactor here: Atomize Pro Refactor
That experience taught me that fewer, well-structured state updates are better than many small ones, and that useEffect is often a sign that there might be a better way.
The Vision: A Single Source of Truth
With those lessons fresh in mind, I approached Wooster's state management with clear principles:
- Single source of truth
- Minimize useEffect usage (that way be dragons - infinite loops and race conditions)
- Predictable state updates
- Type-safe state management
The core state structure emerged from the user journey:
Notice what's not here: no duplicate data, no calculated state, no nested state that could get out of sync. Every piece of data has one home and one way to update it.
The Reducer: Predictable State Updates
Every state change in Wooster goes through a single reducer:
Each action represents an atomic update to the state. No side effects, no complex calculations, just pure state transitions. This made tracking down bugs much easier - if the state is wrong, I just had to check which action last modified it.
Context: Making State Accessible
The context provider became the orchestrator for our state:
That useEffect for initial data loading? It's the only one in our state management, and it's there for a good reason: we need to load data when authentication changes.
The Activities Cache
One of the more interesting patterns emerged when handling destination activities. Instead of fetching them every time, I implemented a simple cache:
State in Action: Creating a Destination
The real test of state management is how it feels in actual components. Let's look at the destination creation flow:
First, the custom hook that encapsulates all the state logic:
And then the component that uses it:
Notice how clean the component stays? All the complexity of state updates is hidden away in the custom hook, leaving the component to focus on what it does best: presenting the interface to the user.
What I Actually Learned
- useEffect is usually a code smell - there's often a better way
- Parallel data loading should be the default, not an optimization
- Cache invalidation is hard, but even simple caching (like my activities cache) can improve UX significantly
- Custom hooks are perfect for encapsulating complex operations (like my destination creation flow)
- Loading states deserve careful handling - users should always know what's happening
- Type safety in state management prevents entire categories of bugs
- Sometimes the simpler solution (useReducer + context) is the right one
The Elephant in the Room: Why Not Redux or Tanstack Query?
While Redux remains powerful for complex applications, Wooster's needs were well-served by useReducer and context. The real decision point was around Tanstack Query - it would have provided better caching, background updates, and error handling out of the box. However, implementing these patterns manually helped me understand exactly what problems these tools solve. For asynchronous state management, something like Tanstack Query would definitely have been better, but I wanted to demonstrate my ability to use native React patterns first. The codebase is now perfectly positioned for a Tanstack Query refactor, with clear boundaries between data fetching, state management, and UI.
A Note on Testing
While building Wooster, I focused initially on establishing solid architectural patterns and core functionality. This choice meant deferring testing to a later phase - a trade-off that taught me valuable lessons about real-world development priorities.
Looking back, several aspects of the architecture would make testing straightforward to implement:
- The reducer's pure functions would be perfect candidates for unit tests
- Custom hooks like useCreateDestination could be tested in isolation
- The clear separation between state management and UI would make integration tests more manageable
For the next phase of development, I'd prioritize:
- Unit tests for the reducer and custom hooks
- Integration tests for key user flows like destination creation
- Component tests for critical UI interactions
My experience with the Atomize Pro refactor particularly highlighted the value of a good test suite when working with complex state management. The ability to refactor confidently relies heavily on comprehensive tests - a lesson I'm taking forward into future projects.
Next up: "Tailwind Patterns: Building a Consistent Design System" (where we'll explore component styling, dark mode implementation, and responsive design - along with a confession about why mobile-first would have been better)