Three different states - Remote, App, and Local
Dayvster 🌊
Posted on April 23, 2024
The Problem - Single State
Have you ever worked on a codebase where the state is nearly impossible to keep track of because it just does so much stuff all at once? Worse yet was it intertwined and reminded you of something a busy spider might weave in the corner of an empty room?
Well, this is a more frequent occurrence than you may imagine. In fact, it's the most common pitfall most developers or groups of developers fall into.
It's super easy to overcomplicate or over-engineer your state if you simply view your state as a singular monolith and not what it should optimally be 3 separate entities that sometimes need to synchronize between each other and not be in constant religious sync.
The Solution - View it as 3 Different States
Those two sketches illustrate some of the points we'll explore in this article in more depth. So let's go ahead and break it down.
The Single State Monolith
Before we can tackle the 3 state approach let's first take a more detailed look into the problem. The single-state monolith is a mistake I've seen plenty of dev teams including some very experienced and senior ones make. It may be a poor mental model of the problem, seniority hubris, a gung-ho attitude to development, or a myriad of different things.
It's super easy to fall into this mental model unaware of the world of pain you're about to inflict upon yourself, your team, and all who will ever have to touch your codebase.
So what is it?
Well to put it simply the Single State Monolith is when you view all the state of your codebase as simply "app state" or global state that always has to be in perfect harmony or sync with each other. Because after all users really really care about their state being persisted throughout every single interaction with your application, right?
So you sync remote and app state frequently, religiously and zealously in fact. You have to be sure that every user change is immediately reflected on the server and that the user gets the freshest and newest data.
You may also decide that local state that lives only in individual components be they react, svelte, solid, or angular is yucky and should be avoided. So all local state belongs to the app state now, because every component needs to be capable of sharing its state with every other component after all.
This line of reasoning or this mental model of your state more often than not results in a very convoluted codebase that makes adding features, changing features, refactoring, and debugging a massive pain and take way longer than it reasonably should.
Problem 1 - Performance
The first and most often problem with this approach is performance. You see when you have a single global app state that every component
has to sync with and be in perfect harmony with you're going to run into performance issues. Most notably unnecessary re-renders.
No matter how good your state management library is, no matter how good your framework is, no matter how good your code is, there is always the human Element.
None of us can hold the entire codebase and all user flows in our heads at once. So as your codebase grows the chances of unnecessary re-renders grow with it.
The other performance hit will be the fact that you're going to have to do a lot of state synchronization between your app state, and remote state, and if you did a
very bad job even your local state. This will always negatively impact your performance and make you write a lot of boilerplate code that could be avoided.
Problem 2 - Debugging
The second problem is debugging. It is much harder to debug the single global state approach. Here's an example that illustrates my point:
You have a component that renders a list of the user's favorite songs, this component gets its data from the app for this example, let's say
we are using slices. This slice will get populated with the data from the server, for which we have to execute a fetch request to effectively pull the remote state
into our app state. Then this slice will be used to populate the list of the user's favorite songs. This list consists of components that take a song object as a prop and render it.
On this list item, we may have a button that allows the user to remove the song from their favorites.
This button will trigger a function that will remove the song from the app state
and then send a request to the server to remove the song from the user's favorites.
Now let's say that the user clicks the button and the song is removed from the app state but the server request fails.
Uh-oh, the user is now in a state where the song is removed from their favorites but the server still thinks it's there.
So let's hope that whoever designed the API for this endpoint was kind enough to give us a correct response if the song was indeed removed or not.
Otherwise, we may have to re-query the server to get the correct state of the user's favorite songs and either re-render the entire list OR do an optimistic update.
As a developer of this application, you will more often than not receive a bug report that says "I removed the song from my favorites but it's still there"
So to debug the problem you will have to go through the entire flow of the user interaction and see where the bug is. Because you have a single global state you can not easily
Annoying right?
Problem 3 - Refactoring
The third problem with this approach is refactoring, which quickly becomes a nightmare when you have a single global state.
Specifically when you have to refactor a feature that is deeply ingrained into your app state. You will have to go through every single
component that uses this feature and refactor it to use the new feature. This is a massive pain and will take a lot of time.
Example: Let's stick to the user's favorite songs example from above and say that we have to refactor the way we store the user's favorite songs.
Instead of storing them as an array we now have to store them as an object with the song ID as the key and the song object as the value.
Depending on the library you chose to handle your app state this may be a massive pain or a minor inconvenience.
But you will still have to go through every single component that uses this structure and refactor it to use the new structure.
Problem 4 - When to sync?
The fourth and possibly the biggest problem is this approach lends itself to over syncing your remote and app state.
Surely you should keep these two in sync at all times for even the simplest of interactions, right? You wouldn't want any user data or interactions to be lost or not persisted right?
Well ok back to the favorite song example, how much do you think the user cares about the song being removed from their favorites immediately?
Will it failing on them once cause them to leave your app and never come back? Will they curse your name and your family for generations to come?
Probably not.
Don't get me wrong providing our users the best experience we can give them is of the utmost importance but there we should also be pragmatic about it.
Does the user care if their interactions with our web app are immediately persisted on the remote state or server side? Is your app dealing with life-or-death situations,
financial transactions or other high-stakes interactions? if the answer to all of those is no. Then maybe you can relax a bit on the syncing of your remote and app state.
Problem 5 - Local State
Where does the app state begin and the local state end? Or is it vice-versa? Should the list of the user's favorite songs be available to every other component in the app?
How often will the list of the user's favorite songs be used in other components? Will it be used at all?
If not then it's perfectly okay to keep the state of that contained within the component itself and not.
Problem 6 - Vendor Lock-In
The final problem with the single global state approach is vendor lock-in. If you chose a state management library
that is not easily replaceable or has a lot of boilerplate code that you have to write to use it.
Then you're effectively locked into that library and will have a hard time replacing it with something else.
And if you do decide to go with another library you will have to refactor nearly your entire codebase to use the new library.
The Solution - View it as 3 separate states
Now let me preface this by saying this is not always applicable and your use cases may be different, however, it is a mental model that helps me structure my projects well.
So instead of trying to shoehorn everything into a single state management library consider splitting it up into 3 separate states, namely:
Remote State
lives on your server this state should be set via POST, PUT, DELETE, PATCH... requests and retrieved via GET requests.
Yes, it is a separate state unto itself because it can be comprised of multiple data sources and streams.
App State or Global State
lives in your application, this is the state of your application so to speak, things that would fall into this state are darkmode, language, user preferences, modals, toasts, notifications, etc.
This state should be occasionally synced with the remote state but not always and not for every single interaction, for example:
say a user is editing their settings should we simply save every single change he has made or ask him to confirm and save the changes at the end?
Naturally, we await the user's confirmation. So then why do we so often avoid this pattern when it comes to other operations? Because it seems outdated, or because the user interactions are so precious and vital that they need to immediately be persisted on the server?
So when and what do we need to sync?
The answer to that question is every developer's favorite response, it depends. Your use case and problem may require frequent sync between the two and there is nothing wrong with that. But at the very least you should give some thought to this issue and ask yourself how crucial is it for this part to be in constant sync between remote and app.
So back to the user's favorite songs example, we can either remove the song immediately upon the user's request from both the server and the app state, verifying that it has indeed been removed. Or we can queue it for deletion from their favorites on the server and store the song as removed from favorites somewhere else, perhaps local storage or indexDb so that upon the next visit the user does not see the song in his favorites list anymore and the server has time to process his request and many others in a batch.
In the above example, the server and app are out of sync, the state of one is not immediately reflected on the other and that's fine.
Now if the example was user A wants to transact money to user B we may want to immediately sync the remote and app state and ensure that the money has indeed been correctly transacted from account A, where account A is the logged-in user.
Local State or Component State
Lives in individual components and manages their state within the component itself. This can be used for dropdown menus, context menus, list items, etc.
Think of any component that has some sort of state that only concerns itself, that is your local component state. If you click on a dropdown menu and it opens up you will rarely if ever need to communicate that to the app state or any other component's state.
This state can easily go lost between navigation and barely any user will notice. Think of this as stuff that only concerns the component in question.
A lot of components can be written purely with local state only, in fact, it will make them a lot more performant and easier to debug if they are, so use them liberally. If you have a component that requires its state to be global consider updating its state on an atomic level as well.
Basically say you have a play button for video or music, it has a play and a pause state, but a double click on a music item will make the song play, naturally the play button in your bottom bat should change its state. A library like jotai or recoil allows us to make a single state atom like isPlaying
which would be a boolean state atom of either true
or false
we can then update this singular atom state globally but keep the rest of the state of the button local.
FAQs?
How do I know what state goes where?
Start by asking yourself the following questions:
Does this state need to be shared between multiple components?
Does this state need to be persisted between navigation?
Does this state need to be persisted between sessions?
Does this state need to be persisted between devices?
Does this state need to be in sync with the server?
Does this state need to be in sync with other states?
How do I sync the states?
There are multiple ways to sync the states, the most common way is to use a state management library like Redux, MobX, Recoil, Jotai, etc.
How is this easier to debug?
It's easier to debug because you can isolate the state that is causing the issue and debug it in isolation. You don't have to worry about the state of other components or the app state.
How is this easier to refactor?
Well since you'll most likely have a separate service that handles your data fetching and updating on the server and another library that will handle your app state and hopefully even allow you to make
atomic updates to your local state if the need arises you can refactor individual parts of your codebase without having to refactor the entire codebase.
Say for example you were using redux but wanted to switch to Zustand, if you previously performed your fetching with RTK and synced it to your singular app state, you'd basically have to
rewrite both of those parts to use Zustand. But if you had a separate service that handled your fetching and updating and a separate library that handled your app state
you could simply refactor the app state part and leave the fetching part as is.
How is this more performant?
It's more performant because you're not syncing the state between every single component and the app state. You're only syncing the state that needs to be synced.
How is this more scalable?
It's more scalable because you can add new features without having to refactor the entire codebase. You can simply add a new state and sync it with the other states.
Additionally you can bypass the state management library entirely and only have remote and local state, if your use case demands it. You probably shouldn't though.
How is this more maintainable?
Libraries get deprecated all the time, even if they are fairly or hugely successful. Don't be too sure it won't happen to you, or who knows they may introduce a breaking change that makes
your entire codebase legacy overnight. I've seen it happen before, a breaking change in a library that a large portion of the codebase was written around can gridlock updates and upgrades to a codebase and make it
perpetually stuck at a certain version.
Best to keep your codebase as library-agnostic as possible, so that you can easily switch out libraries or even write your own if the need arises.
Should I always use this approach?
No, this approach is not always applicable. It depends on your use case and problem. But it's a good mental model to have in your toolbox.
Even if you do go with the single state approach it's always good to keep in mind that not all state changes need to be immediately persisted on the server or in the app state.
So if that's the only lesson you took from this article then I'm happy.
Conclusion
Well, this article went on a bit longer than I originally intended, but if you made it this far I hope you found it useful, and thank you for your patience.
I tried outlining some of my thoughts in this article from my past experiences and mistakes. By no means is this a perfect mental model, heck you may even strongly disagree with it.
But it helped me structure my projects better and I hope it helps you too.
If you have any questions, comments or feedback feel free to reach out to me on Twitter or via email. I'm always happy to chat with fellow developers.
Posted on April 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.