One of my biggest pet peeves with React is how easy it makes it for me to mix business logic with the UI. It feels innocent at first. "Just a useState
will do for now".
Well, that's never the case. Chances are that this value will also be required or set by some other component. So what do we do? We're moving useState
higher in the component hierarchy and we pass it around using composition/context.
Then, we might want to fetch some data. React-query might do the job, but we start fragmenting the code. Who initializes the request? Where are we invalidating our queries? What happens if we need dependent queries? Where do all these live, in a messy useEffect
hook?
Even the most coherent implementation will have some hacks to comply with Reacts lifecycle. After having been bitten by this for quite some time, I prefer to keep my business logic written outside React.
You can use Redux, or Zustand. Any will do. I prefer to use RxJS! You can still get the same sense of organization, with more fine-grained control over the data flow.
I know that RxJS isn’t very loved by a part of the React community. I understand this. If you’re going to take anything from this blog let this be this. There’s no harm in using Redux, or Zustand. If you’re stuck with a SPA, and the URL state is limiting you, by all means, use them. Don’t reinvent the wheel. Here’s a great article about how tedious it is making theuseReducer/useContext
combo performant.
Store set-up
The core idea is that all business logic for our feature page (or complex component) lives inside a closure, outside React. This is where all the nitty-gritty of our application logic happens.
Our React app will receive only the bare minimum it needs to render our components. It will not know any transformations. This allows us to limit the complexity in a single file, easily testable without having to bother with the React lifecycle.
We should still write our integration tests to discover any regressions in our React components. We just have the added benefit that we can test in isolation our business logic.
He's a simple case. We don’t have to guess who will use orderBy
next.
I prefer to export regular Observables instead of BehaviorSubjects. BehaviorSubjects can be updated by using.next(myValue)
from the outside world.
Alright, code...
I can hear you saying, "But isn’t this very ceremonial? useState is simpler".
And I agree, it’s simpler. But what happens when:
- We're asked to reset some other filter, every-time
orderBy
changes. It’s irrelevant to our component. - And then we need to reuse controls like these in a modal/sidebar? We probably need to change our component hierarchy.
Let’s see one case where a value depends on another. Look how elegantly we update the pageIndex
. We either set it explicitly, or it resets when the page size updates. No useEffect
or mixing side-effects in callbacks like handlePageIndexChange
.
No matter who uses these, we ensure that we have separated our design from our application state.
We can write anything. It’s a simple TypeScript file. Let’s see a more expressive one.
Here we use discriminated unions to avoid impossible states. Take a look at line 30. We only export a single boolean Observable. Our component doesn’t care about the # of selected rows. Just the bare minimum. Keeping our UI flexible.
And finally, we can handle network requests with confidence.
- Not bothering with
useEffect
- Not looking for which
useQuery
initialized that request - Canceling, throttling, debouncing, etc, with ease
Connecting to the React world
Again, we have a simple TypeScript file. Nothing React-specific yet. Let’s wire it with our view. We will use Context to pass it around.
React context is a form ofDependency Injection
. It’s not a state-management solution. Its combination withuseState/useReducer
makes it one. There’s no contradiction here.
Here’s how we're going to implement it.
- We're rendering a route with React-Router
- This entry component will create our store, and re-use its instance until it’s unmounted
- It will wrap its children with a context provider, passing down the store
- Every consumer can fetch that store, and subscribe to the stream it needs.
We won’t have extra rerenders. If any other observable than the one we’re subscribing to changes, nothing will happen. We’re good.
Let’s write our one-off boilerplate. Creating a context, typecasting it, and writing our hook for the consumer. The same thing you would do for a useReducer
And finally, some React silliness to ensure that we won’t override our store. Note that we can pass props to our store initialization. It could be the id
of the project we’re working on since we normally get that info from the URL.
And here’s what our page component would look like. A single store that can react to anything.
Now let’s see what a component would look like. It’s not different than the Redux equivalent.
You can also build some Facades. Something likeuseSortBy
and exportorderBy
and its setter, so that the component won’t have full access to the store. I don’t like to optimize that much early on, but it’s a valid call.
And let’s take closer to the useObservable
hook, the glue that keeps everything together. Just plain React. Listen to the subscription, grab the value, bring it as state and move on.
There are various 3rd-party implementations like the one from Observable Hooks. That said, I prefer to "hide" the initial value that React needs for that first render. You're free to use a simpler implementation and provide the initial value directly from the component.
Without React-context
With the addition of useSyncExternalSyncStore
it’s "doable" to omit context. Here’s how the updated useObservable
would look:
There’s an obvious "caveat" that we have to use BehaviorSubject
instead, as we need access to the "current" value in getSnapshot
.
I like my current approach, but if React Suspense requires it, I might revisit it in the future.
Wrapping up
So there’s that. I find this approach gives me all the tools I need to tackle anything. No matter how simple any project starts, it has a solid foundation that will tackle any complexity.
By moving my business logic outside React's lifecycle, I can write expressive code without feeling limited.
Quote“I wonder how many people realize that React Hooks is really just disconnected, less declarative reactive programming.”— Ben Lesh, RxJS Team Lead
If you don’t like RxJS, that’s fine! Use Redux Toolkit and its new RTK Query package. xState? Be my guest, love it.
My only suggestion is that you move away from the useState/useReducer
approach. It doesn’t scale. Especially for start-up environments where there are a lot of moving pieces in the UI. Use your time building features, and not optimizing re-renders.
Resources
- How to write performant React apps with Context
- [React Docs] Fetching data with Effects (check the purple section)
- Hooks Considered Harmful
- Meet the new hook useSyncExternalStore, introduced in React 18 for external stores
- The Facade pattern and applying it to React Hooks
- Do I have to put all my state into Redux? Should I ever use React's setState()?