It's pretty common on the FE to track the status of data being loaded, which I often refer to them as loaders.
This provides the ability to display loading indicators when data is being fetched as well as return other meta data like errors or other relevant information about the data that was fetched. It can also help assist in flow control when there is some hairy business logic that needs to wait for an operation to complete before proceeding to the next step.
More times than not, it's common to colocate the data with the status.
1{
2 posts: {
3 status: 'loading', // or 'error' or 'success'
4 error: '',
5 meta: {},
6 data: []
7 }
8}
On the surface this seems like a great way to ensure the data and the loading state stays in sync. When inspecting your state, it's easy to see at-a-glance the current status of your data. However, there are a couple issues with this pattern.
Data is now nested deeper in your state object #
I keep quoting the zen of python over and over again because it often guides how I think about code design and architecture:
$ python
>>> import this
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
**Flat is better than nested.**
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
Flat is better than nested
In redux
, the kernel of this rule is embedded in the following recommendation:
When I'm debugging some code that relates to my state data, I want to be able to inspect the data quickly. I want the data to be plain JSON so it's easy to read in the console, and I would prefer to not traverse a bunch of nested objects to find the relevant information.
By colocating a loader with the data that is being loaded, it adds unnecessary nesting. Also, now that the data is nested, every query or debug session involves traversing the object tree. This also requires a slew of existential checks to ensure the objects even exist.
Aside: I wrote an article on preventing existential checks, check it out!
Loading state logic requires a data object #
The logic for loading states is now tightly coupled to the data that is being loaded. Like I said previously, on the surface this seems great, but what if you want loading states that are not a 1:1 to a single piece of data?
What if you want a loading state for multiple pieces of data being loaded? To solve that problem with a couple of loaders, you have to create custom logic to wait for all loaders to be completed before proceeding. What happens when of the loaders fail or never finish? This is all ad-hoc logic and complexity stapled on top of a design for something that it was never intended to solve.
Enter decoupled loaders #
Instead, what I do in my projects with global state, like redux
: I create a
separate data store for all my loaders:
1{
2 posts: [],
3 loaders: {
4 posts: {
5 status: 'loading',
6 error: '',
7 meta: {},
8 }
9 },
10}
In this example, the posts
data is at the top of the state data and all of my
loaders are in a separate slice.
Here is an example of how I would manage the loading state:
1async fetchPosts() {
2 startLoader({ id: 'posts' });
3
4 const result = await fetch('/posts');
5
6 if (!result.ok) {
7 errorLoader({ id: 'posts', error: 'failed' });
8 return;
9 }
10
11 const post = await result.json();
12 successLoader({ id: 'posts', meta: { id: post.id } });
13}
A react component might look something like this:
1import { fetchPosts } from "./api";
2import { useLoader } from "./hooks";
3
4const Page = () => {
5 const loader = useLoader("posts"); // find loader by id
6
7 useEffect(() => {
8 fetchPosts();
9 }, []);
10
11 if (loader.loading) return <Spinner />;
12
13 return (
14 <div>
15 {posts.map((p) => <div key={p.id}>{p.title}</div>)}
16 </div>
17 );
18};
The logic is exactly the same, but the beauty of decoupling the logic of the loading state is you can now use the loaders for any asynchronous actions in our app.
For example, what if we want to have an initial loader to fetch a bunch of data:
1async fetchInitialData() {
2 startLoader({ id: 'initial-data' });
3
4 await fetchUser();
5 await fetchPosts();
6 await fetchComments();
7
8 successLoader({ id: 'initial-data' });
9}
Or what if we have to do some CPU intensive task
1async intensity() {
2 startLoader({ id: 'intensity' });
3
4 while (notFinished) {
5 // create the universe
6 }
7
8 successLoader({ id: 'intensity' });
9}
You can now reuse the same component logic as you do for loading single pieces of data!
1const loader = useLoader("intensity");
2// or useLoader('initial-data');
3// or useLoader('posts');
4// it's all the same!
In this way, our loaders become generic status trackers for any operation the user or app might trigger. This provides way more flexibility than a loader that is tightly coupled to fetching single pieces of data.
For all my react/redux projects, I generally use robodux which has a slice helper called createLoaderTable which encapsulates this decoupled loading logic.
As a result, every project can reuse the same decoupled loaders. It works well for small projects as well as large enterprise ones.