Bunny Hops and State Digging: Revamping the State Management Burrow

|

A billion here, a billion there~

“A billion here, a billion there~ and pretty soon you’re talking real money.”

–Senator Everett Dirksen

From the multitude of items on our backlog at Samsung Ads, some of the ones I always keep close to my heart are the ones related to clearing technical debt. If I wasn’t clear, technical debt is a special kind of evil, with a steep interest rate, that entraps you until only drastic measures and difficult choices are left. They often become cumbersome projects involving multiple teams across multiple domains if tackled too late.

With this in mind, this article will explore one of the ways we have driven away tech debt in the Samsung Ads’ Front-End codebases by ridding ourselves of older global state management habits. 

The good old days-ish

As UI engineers, we’ve all been there. The struggle of managing the state of complex web applications can be overwhelming. In the early days of React, we were forced to navigate a lawless era of states, where components were made from classes and code was difficult to comprehend, making our codebases more convoluted than they ought to be. Blame the times and lack of better options. 

In class components, the state was typically managed within the component itself, which meant a large amount of state being managed in a single location and no standardized way to share state between components. Leading to prop drilling, where the state needs to be passed down through multiple layers of components, even if those components don’t use or need the state themselves.
Consider the following schema for a simplified example: Imagine you need to display the currently selected Item in the Title of the Header. Your state would need to be contained in the topmost App layer, and then be propagated to all the layers until Item and Title. Welcome to the road to hell.

Thankfully, Redux came along and revolutionized state management in React applications, quickly becoming the industry standard (it’s estimated that it is still used on 30%+ of React projects).

Blessings and Curses

Redux has been a blessing for the React community in many ways. Before, there was no de facto standard for state management in React, which meant that engineers had to come up with their own solutions. This led to a wide array of state management approaches, making it difficult for developers to switch between projects or collaborate with other teams. Redux provided a widely-adopted solution to the problem of global state management, and it became a skill just as important as vanilla React.
Unfortunately, Redux can also be a curse if overused or used inappropriately.

The tradeoff that Redux offers is to add indirection to decouple “what happened” from “how things change”.

–Dan Abramov

Redux code is recognizable by its verbose syntax and a lot of boilerplate code, which can make it difficult to quickly understand the codebase. Let’s consider what we would need to implement for a simple state at the component level with Redux:

  • Stores > Where your global state resides 
  • Slices > Subsections of your global store, optional but often needed
  • Models > Type definitions
  • Actions > The triggers allowing UI interactions
  • Reducer > Translating Actions into State changes

Each of these building blocks, due to their respective footprint, is a separate file. Which results in spreading the logic across the application instead of keeping it contained to where it originates from: the component itself.

Now let’s compare with what would be needed to achieve that same state management in vanilla React:

const [state, setState] = useState({ ... })

Welp, that’s it. Slap an immer function on it and you will even achieve immutability at the cost of a few characters.

Moreover, because Redux requires developers to create a lot of separate files and follow a specific structure, it makes it difficult to move away from Redux; thus, creating a lock-in effect that ultimately leads to a growing amount of tech debt.

The Evolution of React

But as React has evolved over the years, with the introduction of contexts in version 16.3 and hooks in version 16.8, the need for global state libraries has decreased.

Contexts provide a convenient way of passing data through the component tree without manually passing props at every level. They can be used to share states between components that are not directly related to each other, such as a user’s authentication status. The following diagram illustrates this concept:

Hooks provide a way of managing state inside functional components, reducing the need for class components and making it easier to reuse stateful logic.

We’ve already seen one earlier for state management:

const [state, setState] = useState({ ... })

You could also handle lifecycle events like a method to execute on initialization:

useEffect(() => { 
  // init methods
  return () => { /* cleanup */}
}, [])

Or even subscribe to external stores:

const itemsAPI = useSyncExternalStore(subscribe, getSnapshot)

Thanks to these improvements, the need for global state libraries has decreased significantly. We can now manage most of our state locally within our components, using hooks to manage the stateful logic and contexts to pass data down the component tree.

Of course, there are still cases where we need to manage the global state, such as when sharing data between unrelated parts of our application. In these cases, we can still use Redux or other global state libraries; however, it is strongly recommended to use what’s available in React before reaching for a third-party library.

Server state sync… what now?

Nowadays it’s essential to differentiate between server state and user state when it comes to modern state management. The server state should reflect the data stored on the server and not be mixed with user interactions to avoid bugs.

Once you grasp the nature of the server state in your application, even more challenges arise as you go, like caching, updating stale data, knowing when to update, reflecting changes as fast as possible, memoization, …

Thankfully, there are libraries for this:

  • TanStack/Query
  • SWR
  • Apollo

Using them makes things as succinct as:

const { status, data, error, isFetching } =
	useQuery("posts", async () =>
		await axios.get("url").then(res => res.data))

Let’s break that down a little, we can see that the variables assigned expose all the lifecycle events (eg: is the data loading, available or in an error state) you could need, then the function assigns your query result into the “posts” key in your local cache, and finally the call itself is a regular query.

To summarize, this single line provides you with:

  • Caching, Stale data while Revalidating by default
    • Quick time to interactive
    • Allowing multiple unrelated components to use the same data without contexts/prop drilling
  • React lifecycle event
    • Build easy UX flows such as loading/error display
  • Re-rendering as needed when a state changes/refresh
    • No more hazardous use of useEffect, a common issue of React apps
  • Batching in the case of multiple queries

Essentially externalizing most of the heavy lifting required to show the various life cycles attached to querying, allowing you to focus solely on your users’ interactions.

So, what’s left?

Once you’ve cleared all your server states from your usual component flow, you should be left with very little to take care of. UI state refers to data that is stored and used by the UI components of the application. This can include things like:

  • Form input values
  • Component visibility
  • Elements ordering
  • Table filtering
  • Cursor/Scroll position

UI state is typically updated in response to user interactions or other events, such as a component lifecycle method. It is generally considered to be more volatile and less permanent than the server state. It can be updated frequently and quickly and does not require any backend processing.

Location, location, location.

Location, location, location.

–William Dillard, but also, less directly, Kent C. Dodd.

Now that our state logic is neatly separated, we can also make use of one of our strongest development principles. The more indirect your state is from the UI that’s using it, e.g. global stores against local state hooks, the harder it is to maintain. Your main goal with a modern state management stack is to be able to open a component and see at a glance which server data it synchronizes to, and what are the expected interactions from your users.

Too long, didn’t read.

Alright, I’ll make it short.

Don’t walk, run. Getting away from global stores as fast as you can.  If not, it will likely lead to an insurmountable amount of tech debt.  

How? Separate your state into server and UI and let server-state synchronization libraries handle the heavy lifting for you. While doing this, keep things close together and always remember to keep it simple.

References

https://blog.isquaredsoftware.com/2022/07/npm-package-market-share-estimates/
https://en.wikipedia.org/wiki/Boilerplate_code
https://redux.js.org/understanding/thinking-in-redux/glossary
https://immerjs.github.io/immer/
https://react.dev/learn/passing-data-deeply-with-context
https://react.dev/reference/react
https://stackoverflow.blog/2021/10/20/why-hooks-are-the-best-thing-to-happen-to-react/
https://developer.mozilla.org/en-US/docs/Glossary/Time_to_interactive