Lub-e-lubab of state management in React

Lub-e-lubab of state management in React

·

10 min read

Managing state is one of the trickiest parts of building React applications, and it’s easy to over-engineer solutions in the pursuit of "doing it right." While global state often gets the spotlight, the reality is that most state in an app doesn’t need to be global. Overthinking local state management—like form inputs, modals, or toggles—can lead to unnecessary complexity and make your app harder to maintain. A lot of this complexity comes from treating all state as if it needs to scale to every part of the application.

React’s strength lies in its component-based architecture, and the same principles apply to managing state. State should live as close as possible to where it’s used. If a toggle is only relevant to one component, its state doesn’t belong in a Context or a global store—it belongs inside the component itself. By organizing state in this way, not only do you avoid unnecessary abstractions, but your app’s structure naturally mirrors its functionality. This makes it easier to debug, test, and scale without introducing the kind of rigid architecture that can slow you down.

This article expands on a topic I previously shared in a LinkedIn post. If the content of that post piques your interest, feel free to continue reading the full article for a deeper dive into the concepts. https://www.linkedin.com/feed/update/urn:li:activity:7273591576818249728/

Why this article?

I’ve been working with React for a few years now, and one thing I’ve noticed is how often people overcomplicate or overlook the basics of state management. The key is to keep it simple—at least in the beginning. Start with what makes sense for your project’s current scope, and then gradually refine how state is managed and shared as the app grows. This approach has worked for me in every project, and it often means I can avoid pulling in extra dependencies altogether.

This article focuses on React’s built-in tools for state management. That said, I’ll give credit where it’s due and highlight a few external libraries along the way—because sometimes reinventing the wheel isn’t worth the effort.

Let’s hop in!

Starting with useState to manage the bread selection state? Simple, right? If this is all you need, it works perfectly. But the complexity grows quickly when you're managing multiple states—like bread, size, toasting, toppings, and sauces.

Now we have multiple states (5+) and methods to switch them. This is the point where it's clear that moving these states and their logic to useReducer makes sense. At this stage, your file is already around 150+ lines with JSX included, and things are starting to get messy.

Move the useReducer logic into a separate file, primarily inside similar folder of this page.
For example, if the folder of this file was SandwichBuilder, then /sandwichBuilder/sandwichBuilder.utils.js

And is our JSX now:

With this structure, the utils file is now responsible for the state logic, while the SandwichBuilder.js component focuses on rendering and interacting with the state. This keeps the code modular and easy to maintain as your app grows.

More Problems

Sooner or later, you'll need to split your JSX into separate components—whether global or local—to reduce clutter and better manage specific logic. This abstraction helps keep your files clean, but it also introduces the need to pass props or methods down the component tree. And that's where prop drilling kicks in, making the process less manageable, especially when your state is shared across multiple components.

Now, you can notice the methods and states being transferred to child components as props which is not something we want for the longer run.

Context comes in!

The best way to avoid excessive prop drilling is by using React's Context API. Context allows us to share state across the entire component tree without having to pass props down manually. This is especially helpful when you have multiple components that need access to the same state or logic, like in our Sandwich Builder example.

Let’s refactor our SandwichBuilder to use Context for state management. This will allow the state and dispatch to be made available to all components that need them, without passing them down as props.

Keeping a separate file inside the same folder: /sandwichBuilder/sandwichBuilder.context.js

Then you will be using the Context inside child components in this way:

Now, what we have is a scalable solution with business logic separate from the JSX. This makes the code cleaner, more maintainable, and easier to manage as your app grows. By keeping state management isolated in the context and reducer, your components remain focused on presentation, while the state logic is neatly encapsulated. This separation of concerns ensures that any change in state handling doesn’t unnecessarily affect the structure of your UI components.

Furthermore, by using context, we've avoided prop drilling, which would become cumbersome as the component tree deepens. Whether you’re adding more sandwich ingredients, new steps to the builder, or other features, the structure we’ve set up makes it easy to introduce more logic and components without worrying about a tangled mess of props being passed down through every level.

💡Things to remember

By introducing context into your application, several key points to have in mind:

  • Any change to the context value triggers a re-render in all components that consume that context. This means that whenever the state inside the context provider is updated, all child components that rely on that context will re-render, regardless of whether they actually need the updated data or not.

  • Utilize useMemo, useCallback effectively to avoid re-renders as far as you can. This is do-able if you understand how React works under the hood.

  • Use multiple contexts. (See the next example)

To mitigate unnecessary re-renders caused by context, a useful pattern is to split the context into two separate contexts: one for state and another for dispatch. This separation ensures that components only subscribe to the context they need, reducing unnecessary updates.

Note: In-case if you have too much performance issues still existing, kindly go through the Children pattern. Article

Custom Hooks

The next step is to introduce Custom Hooks for modularity and better reusability. By now, we've separated our state and dispatch contexts and demonstrated their usage. However, as the app scales, you might find yourself repeatedly writing similar logic across components (e.g., updating bread, handling toppings, etc.).

Custom hooks can help encapsulate specific logic and improve readability while reducing repetition.

We might not need custom hooks here at all. Read the text below code.😢

Custom hooks can feel like overkill in our current example, but they shine when you need to encapsulate reusable logic. For instance, while our SandwichBuilder page doesn't strictly demand custom hooks, there are cases where they become invaluable. Think about scenarios like creating useBreakpoint for responsive logic or useFetch for handling API calls. These hooks provide a central, reusable layer of logic accessible throughout your app.

A great use case would be abstracting operations that interact with your backend. Hooks like useGetProducts or useGetReviews can encapsulate fetching logic, error handling, and state management, all in one tidy function. Not only does this reduce duplication, but it also keeps your components focused purely on rendering and interaction. Custom hooks are a powerful tool when the situation calls for them.

URL state persistent

Remember the old-school way of storing information in URLs? Well, it’s not so old-school anymore. In modern applications, URL-based state management is still a critical feature, especially for persistence and shareability. If you’re not incorporating it into your app, you’re likely missing out on a key functionality that users expect. 🤫 I’ll talk about it’s advantages after the code example.

Let’s incorporate pagination, sorting, and filtering into our SandwichBuilder app and store these states in the URL. This approach ensures the state persists across refreshes and enables sharing a specific state via URL. Here's how it might look:

Now, this implementation leverages the power of URL state persistence to make the SandwichBuilder app more user-friendly and practical. By syncing the state (like page, sort, and filter) with the URL, we ensure that users have a consistent experience, even when they refresh the page or share the link with others.

For example, if a user applies a filter for "Cheese Sandwich," sorts by price, and navigates to page 3, the URL reflects these parameters. If they bookmark or share this URL, the recipient will see the exact same filtered, sorted, and paginated results—no additional clicks needed to recreate the state.

This approach also simplifies debugging, as developers can see the application's state directly in the URL without needing to inspect internal states in the console.

Some Key advantages of this approach:

  • Users can refresh the page without losing their current view, making the app more intuitive and resilient.

  • The ability to share a link with the exact state of the app (e.g., filters, sorting, and page) adds a layer of convenience, especially for collaborative use cases or customer support.

  • Search engines can index unique states of the app (e.g., a specific filter or sort), improving discoverability for specific searches.

  • With the state visible in the URL, developers can quickly reproduce issues by copying and pasting a problematic URL.

  • URL state persistence removes the need for local storage or session storage for temporary UI states, reducing complexity.

You can find several other use cases where URL-based state management outperforms regular state management. I’ve only highlighted the most significant cases, such as filtering and pagination, where this approach truly shines. But beyond these, URL persistence is also useful for tracking user progress, saving specific search queries, handling multi-step forms, or even managing multi-view or tab navigation.

Refs

Refs aren’t exactly about state management, but they can certainly play a role in stateful interactions. The power of refs lies in their ability to give you direct access to DOM elements without triggering re-renders. This makes them particularly useful when you need to interact with the DOM in a more manual way, such as focusing on elements, measuring them, or integrating with external libraries.

One of the key benefits of refs is that they do not re-render your components. This can be incredibly useful in situations where you're trying to control behavior without triggering a full re-render of the component. While it’s generally a best practice to manage state via React’s state management system, refs offer a workaround for cases where you want to avoid re-renders—especially when dealing with forms.

🤡 Here’s an example demonstrating an Uncontrolled Form with refs:

While this approach minimizes re-renders and is helpful in certain cases, it’s worth noting that this can lead to a less predictable behavior compared to controlled components, where input values are managed via React state. As a general rule, use uncontrolled components with refs when performance is critical and re-renders are undesirable, but for most scenarios, controlled components are preferable for consistency and easier debugging.

Shoutouts to 3rd party libraries

Well, here we are. I’ve kept the focus of this article on React's built-in tools for a reason—it's about going native and understanding the fundamentals first. Sure, everyone is diving deep into third-party libraries these days, and while they’re great at handling a lot of the headaches, like useCallback memoization, they can also make us forget the basics. Don’t just rush to libraries to solve every little issue, learn to handle state with React’s native tools before jumping into that rabbit hole.

I’m currently using all of these libraries in my personal projects, so these insights are based on practical, real-world examples. They’ve proven to be invaluable in managing state, data fetching, and form handling, and can make a big difference in the scalability and maintainability of your applications.

Here are some honourable mentions:

  • Tanstack Query: If you're handling data fetching and caching, React Query is a game changer. It simplifies managing server-state, caching responses, and synchronizing data, so you don't have to manually manage state or make complex API calls. https://tanstack.com/query/v3/

  • Zustand: For simpler, more scalable state management, Zustand is a small but highly efficient state management solution. With its easy-to-use API, it eliminates the boilerplate associated with larger state management libraries like Redux, while still providing a robust way to manage state in your React app. https://zustand-demo.pmnd.rs/

  • React Hook Form: React Hook Form simplifies form handling in React by reducing re-renders and providing easy access to form values. It's ideal for handling complex forms with minimal overhead, improving performance while keeping the codebase clean. https://react-hook-form.com/

  • Nuqs: Type-safe search params state manager for React. https://nuqs.47ng.com/

Well, you’ve made it to the end. If you've followed along and understood the concepts, you're on the right track. But don’t just take these as quick fixes—apply them, practice them, and remember that React’s built-in tools are your foundation. Don’t get too cozy with shortcuts; they’ll come back to bite you when things get messy. Now get to work. 🌻