Recently, the company I am at has been putting in effort to improve our frontend. In the process of this, we have discovered that one of the reasons we are in the muddy water is that we have adopted a bunch of frontend libraries and toolings without real understanding of their intended use cases and used them pretty randomly. Plus, there are anti-patterns.
So in this part, we are going to talk about these tools and how I am thinking about adopting/using them. And how they help in different patterns.
For context, we are doing nothing special - just building a React SPA. As of now, we have below list of libraries that are currently in use.
The first area we are going to talk about is how long we persist certain data in frontend and where. Of course it's really just either in broswer tooling or in memory but with these different tools, we can categorize them more granularly.
Let's draw up a table for it.
Persistence Location | Lifecycle Length | Examples |
---|---|---|
Local Storage | Forever until wiped explicitly | N/A |
Global State Management tool | Same lifecycle of the SPA. Wiped when app reloads | Redux, Jotai |
React Context API | Same lifecycle of the provider component. Wiped when provider component unmounts | N/A |
Request Cache | Depending on the specific tool but usually has cache key and expiry configured. | TanStack Query, URQL |
With this table in mind, we can now talk about some anti patterns I have seen.
For a long time, we have been abusing local storage and sort of treated it as the global state management system. This is problematic because for one, it is not, local strage doesn't gets wiped clean when the entire application unmounts. There will be data persisted in the local storage the next time the application gets loaded up and when handled incorrectly, can trip up the entire app. For two, anything you store in local storage is out of your control - it's in client's browser. You can do things like wiping local stroage on user signing out but it still might corner you into a messy situation.
So what's the good pattern here? The answer is straightforward - putting auth information into local storage and using the auth information to load up the user information upon app loading. With only auth information in the local storage (in our case, a standard JWT token), we can ensure everytime an authenticated user loads up the application, the user information is fresh and correct. Then if you need to propagate this user information across the entire app, store it in your global state management system.
Another instance I have seen is too eagerly hoisting everything up into the global state. One time I heard "we open up a new tab because there was some weird issue if we use react router navigate back to it, it's just a workaround.". Translation: too many things are hoisted up into the global state so we can't properly clean up the global state when trying to navigate back into certain view. If this is the case, maybe context API is the right play as the state it holds gets created and cleaned up with the provider component mounted and unmounted. As long as we placed the provider component in the right place in the component tree, the component lifecycle itself already does the state initialization and cleanup for us.
This is also somewhat related and to some degree, caused by the previous topic of "hoisting everything up into redux".
There are some supposedly reusable functions/hooks that looks like this.
// adding returning type here to emphasize on the fact it returns nothing
function getXXX(): void {
...
}
But if you read the code you realize it read some parameters from redux, send some request to fetch the data then store the data back into redux.
This is why I used the word "supposedly" earlier - if there is no comment, no one telling you what it doesn or not readin the code itself, how does the function make sense? not to mention reusability. It's called "get something" but doesn't accept any arguments or returning anything.
To zoom out a little bit, we all heard of the concept of a pure function aka a function with no side effect. Here, we are doing the exact opposite, we are writing a function that's pure side effect LOL.
This type of function defeats the purpose of reusability, readability and maintainabillity as everything is so tightly coupled with the redux store.
To make this better, the getXXX()
function could be pure and leave the redux store related stuff out of it.
e.g.
function getXXX(arg1: T): Data {...}
...
const arg1 = store.someDataSlice.something
const data = getXXX(arg1)
dispatch(setData(data))
...
Also closely related to the previous topic, we also have issues with blowing up a reusable hook.
Below I am using this picture from Vue's composition API but React hooks and Vue's composition API are conceptually the same and are designed to solve the same problem - which is to group related functionalities closer together.
A react hook is a function - thus it should be written following almost the same set of good practices for writing a normal function. I say "almost" because you will most likely alter state and thus it probably won't be pure. But otherwise, most of stuff is the same - you accept arguments, making sure the function name is readable, etc. One of the issues I have seen in our code base is we almost NEVER write a hook that accepts arguments, we just grab everything from the redux store - and this is the same problem we have talked about in the previous section.
And there are generally two situations that pushes developers to start writing hooks outside of the component.
Note this paragraph is writting on 2025/03/12
React's reactivity model is somewhat backwards comparing to almost any other framework or even vanilla JS. This is something I came to truly realize recently and I think it's worth talking about it.
Before this job, I used Vue for almost 3 years and when I got back to React I just assumed they are similar in reactivity model but that can't be further from the truth.
I am going to use Vue and plain JS in comparision to talk about this section.
In other frameworks, things are presumed not reactive until made reactive. For example, to make an value reactive in vue you need to either make it a ref or a reactive object.
import { ref, reactive } from 'vue'
// within the setup function
const reactiveNumber = ref(1)
const reactiveObject = reactive({})
But react does it the other way around, React re-run the component for EVERY RERENDER. Meaning, if you think of rendering a functional component just as executing a that function. That function gets executed for every change that needs to happen. What this means is every line of code in your component gets execute with every change. More specifically, if you define a function within the component, the memory allocation of that function happens everytime the component updates. This might not seem to be a lot but as the application grows, it will take a toll on the performance. (try google search "why is every react application slow")
This is ultimately the reason React emphasizes so much on those useCallback()
and useMemo()
hooks. Those hooks under the hook extract the code within them out of the component re-renders and put them back in after re-renders.
const SomeReactComponent = () => {
// will change with every re-render
const currentTime = new Date()
// set one time on mount
const fistTimeRenderDate = useMemo(() => new Date(), [])
...
}
The point of this section is - BE AWARE of everything that you put into the component. Sometimes a harmless helper function defined in the component might just be the final straw on the performance.