State Management in React without Redux

David Garner
Mintel Tech Blog
Published in
11 min readApr 9, 2020

React apps can be built and scaled without introducing state management libraries, but design and refactoring is needed to avoid the code congealing into a great big Blob.

A monstrous octopus.
Photo by Jonas Friese on Unsplash

At Mintel, we recently completed a project on our client-facing homepage to better support searching for content on some of our older products, while also upgrading the UI with a new design. We took this as an opportunity to move away from some of the older tools and approaches that slowed down development, such as the MVC framework Backbone, the discontinued headless browser PhantomJS, and a CSS build with frequently-clashing globally-applied styles¹. We decided to rewrite the homepage frontend from scratch while keeping the legacy content pages and server-side APIs mostly unchanged, switching to using React to build the UI, Jest to run the tests without needing a headless browser, and SASS with CSS modules to implement local CSS at a component level.

We considered introducing Redux to manage the state of the app, but decided that it wasn’t necessary, and instead opted to manage state just using pure React. Nine months on, I still think this was the right decision, but it did come with some costs:

  • There were less clear divisions between the different components’ responsibilities in the app;
  • Some parts of the app developed an unnecessarily complex state and update model, which would have been much simpler with a centralised action/reducer architecture;
  • Most of the state and data-fetching effects of the app accumulated into a single component, which became the React equivalent of The Blob: an anti-pattern in object-oriented programming.

These issues are not uniquely solved by using Redux. But in cases where a state management library is not appropriate for an app, then knowledge of React principles and common design patterns, and newer features like Context and Hooks, provide the best means of breaking down monoliths into smaller and more maintainable components.

The State of the Search App

The Mintel homepage for clients.
The Mintel homepage for clients.

The Mintel homepage for clients is essentially a web-based search app for Mintel’s content. We provide clients with multiple types of written content on our market intelligence, ranging from in-depth annual reports on a particular market or industry, to brief observations on a particular consumer trend. Clients can find content by entering a text search term, or by filtering down to a particular region, category, or content type. They can also change the presentation of the search results by selecting to view the results in a list or in a “cards” grid view, or ordering the results by date or by relevance.

Would Redux help?

Redux is a state container for the global state of a JavaScript app. It enforces deterministic and simple state changes by requiring that all changes to the state are triggered by dispatching actions, which are handled by pure functions called reducers. When used with react-redux, it makes global state easy to access deep in the component tree, avoiding the problem of “prop-drilling” where multiple components become aware of data that they themselves do not use directly.

When considering whether or not to use Redux for the rebuilt version of the app, we thought about the different types of state that the app needed to maintain, and whether Redux would help with managing that state.

URL state

The applied search criteria are synchronised with the URL.

The main mutable state of the app is the search criteria state. All interactions that a client performs to manipulate the search results and their presentation needs to be “bookmarkable”, so that a client can bookmark the page and recover that search at a later date. This means that the text search term, applied filters, sort order, and presentation of the results are recorded in the query parameters of the page, and are updated without a full-page load using the history API.

On past projects at Mintel, we have used asynchronous effects Redux middleware to transform user interaction actions into URL-modifying actions, keeping the Redux store and URL in sync. For example, clicking a search filter checkbox with URL syncing would dispatch an action of type “TOGGLE_FILTER”, and a Redux saga would listen for this action and trigger a “push” routing action with the filter added to the query parameters, which is caught and used by the middleware.

This approach works, but it requires additional work from the developer to keep the store state in sync with the URL state. For the example above, the app also needs to convert “pop” routing actions into UI actions to allow backwards and forwards browser navigation to update the search results.

A core principle of both React and Redux is to avoid duplicating state. In React, if a component owns some state, then in most cases it should not be copied into another component’s state via props. This React blog post discusses this anti-pattern and alternative approaches. A similar principle holds for Redux: if some part of the app owns some state, then it should not be placed in the global store, as it risks going out-of-sync with the true source of the state, and puts the onus on the developer to keep the state in sync.

For these reasons, we knew that if we introduced Redux to the app, then the source of state of the search criteria would be the URL and not the Redux store.

API-fetched state

The other key state of the app is the search results. There are several types of search results pulled from different endpoints, and most of them should be refreshed when the search criteria is changed. Once this data is fetched and transformed, it is only ever appended to or replaced, and never mutated.

One way to trigger these fetch requests is with Redux actions and sagas. However, as the search criteria state would not live in a Redux store, these actions would only trigger API fetches and not directly affect the search criteria state. Actions would also need to be dispatched on pop changes to the history, so an event listener would need to be set up on the history object directly too. This would work, but it requires the developer to always ensure that search criteria changes are accompanied by API requests wherever they are triggered in the app.

It is simpler and more declarative to trigger these fetches based on the new state of the app rather than from the interaction that a user performed to arrive at the new state. This makes the useEffect Hook a more natural candidate to handle search fetches than a Redux reducer and a saga. The search criteria can be pulled out of the URL and put into a useEffect call’s dependencies, triggering the API requests in the component directly when the criteria is out-of-date².

If a component or hook coordinates the fetching of a resource in a single place, then it makes sense for that same component or hook to maintain the state of that fetched resource too. This means that the search results are more appropriate in React hook state than in Redux state.

Not using Redux

useHooks. Photo by Dave Phillips on Unsplash

The main state of our search app should not live in a Redux store. The rest of the global state of the app is user-specific data that does not change within the run-time of the page, or is already handled separately by other internal Mintel React libraries.

For these reasons, we decided to create the app without Redux and just use React’s built-in component state. We briefly considered some other state management alternatives like MobX and Apollo Client, but those would have had the same issues of state duplication, and we had less experience with these libraries.

There were some other factors in this decision. Most of the team had some React experience but no Redux experience. Of those of us that had used Redux before, we felt that Redux encouraged a lot more boilerplate than pure React. Redux does not handle asynchronous effects in itself, so an extra library would be required to provide this, and our experience with Redux Saga on previous projects was that it was hard to test usefully.

Finally, the app was a single-page search app with one route. Introducing a state management library for this use case felt like overkill.

The Consequences

We built the app and released it to our clients. The design choices made up front worked: React components are easy to build and test in isolation using tools such as Storybook and Jest, and the API requests were simple enough to be handled within the components themselves using useEffect Hooks. However, as the code developed, there were some issues in the code that can be directly or indirectly attributed to the lack of a framework or established architecture.

Frameworks often come with established conventions on how code should be structured. While React and Redux are described in their documentation as libraries and not frameworks, together they play a similar role to a full framework in many apps. The libraries themselves are resolutely unopinionated on how codebases using them should be organised, but there are some common conventions adopted and promoted by the Redux community. These include separating state and reducer code away from components, introducing “selectors” to pull pieces of state out of the global store, using “action creators” to standardise the forms of actions, separating out slices of associated Redux state into “ducks”, and partitioning Redux-aware “Container” components from Redux-unaware “Presentational” components.

These conventions help to break up a Redux app and its components by their roles and responsibilities. Without any equivalent patterns being established in our app’s pure React codebase, a few of our components ended up coordinating all the state and side effects of the app, which resulted in bloated components.

In React, data flows down the component hierarchy. Without an external state manager, this inevitably results in components at the top of the tree owning state used throughout the app. In particular, a single component named “Search” grew into the the role of the global state manager. Without a side-effect-managing library like Redux Saga, this component also handled the asynchronous search results fetching within the app in useEffect calls, which became complex and interdependent as more search criteria were added. As this was the main component that consumed the search criteria state from the URL, this component also ended up uniquely handling the reading and updating of this state to the URL, and passing data and callbacks down to its child components via props.

This component became a dumping ground for all new search-results-related state and effects in the app, and became The Blob. The cost of doing this is the same as for any other God Object: it knows too much, is harder to reason and follow, and hard to refactor. It’s also hard-to-test, as a lot of logic lives in the same component and all the asynchronous effects need to be mocked for the tests to work reliably.

How it can be improved

“The alternative to good design is always bad design. There is no such thing as no design.” — Adam Judge, Author of “The Little Black Book of Design”

To refactor The Blob, break it up. Photo by Vadim Sherbakov on Unsplash

All of these issues are oversights in the design of the app rather than a necessary consequence of writing React code without Redux. While Redux can provide community-established standard conventions which guide you to more decoupled and isolated code, it is not the only means of doing this.

For improving the separation of components’ responsibilities: we could still separate components into those with knowledge of the global state (in our app, the URL state and static state in Context) and those without, in a similar way to the Containers and Presentational components pattern… but this arguably goes against the ethos of Hooks. Hooks were introduced to allow state and side-effect reuse in multiple components, but are also useful at separating out the container-like complex state and side-effect logic of a single component from its presentational logic, even when the Hook is not used in multiple components. Some prominent frontend developers have moved away from the Container pattern and advocate using Hooks instead.

The Blob Search component can be broken down. Reusable Hooks can be created to pull the search criteria state from the URL in an easy-to-use and easy-to-update manner. As well as simplifying the logic within the Search component itself, this helps alleviate prop-drilling by providing a pattern for deeply-nested components to use the URL state directly. The search criteria state was never owned by the Search component: it was simply tapped into at that point.

The complex search-result-updating logic still needs to live at the top of the tree, but single-use Hooks can be extracted out that handle the search result fetching, updating, and caching. The remaining state which does not need to live in the Search component can either be split off into separate components which provide this via Context, or simply moved down the tree to wherever it is needed.

Conclusion

There are plenty of improvements that we can make in this app, but introducing Redux or any other state-handling tool doesn’t address the issues we identified at the beginning of the project. Most of the state of the app has a different source of truth than a Redux store, and the conveniences of Redux’s pure reducer state model are outweighed by the burden of having to synchronise the Redux store with URL state. The issues that came about as the codebase grew can be addressed by proactively refactoring the code, breaking down overgrown components, and extracting out truly reusable Hooks for interacting with global state.

Redux is a powerful and highly-scalable library which underlies many complex production apps developed at Mintel. While it was not the right fit for this project, it is a valuable tool in any React developer’s toolkit, and should still be seriously considered on new projects, even with the advent of Hooks and newer frameworks.

¹ This presentation gives an excellent overview of the issues of global CSS rules at scale.

² Recently, React has started recommending triggering fetch effects at the point of user interaction rather than on render, as this prevents “waterfalls” where a series of fetch requests are dependent on each other before being initialised. In our case, no other requests are dependent on the rendering of the search results, so we do not run into this issue.

--

--