RTK Query Loader is a NPM package that I have written and maintained for over a year now. I wanted to write an article about the experience, stuff I've learned and where this package is at now.
Origins
It all started with an abstraction at work which looked something like this:
app.tsx
const App = () => (
<RTKLoader
queries=[[query1, query2]]
onSuccess={(data) => <LoadedComponent data={data} />}
onLoading={() => <InlineLoading />}
onError={(error) => <ErrorView error={error} />}
/>
)
Pros & Cons
This was very helpful, some parts of it I loved but other parts of the pattern were very annoying.
- I have to create a wrapper, and an internal
<LoadedComponent>
whenever I use the pattern - Setting up loading/error views all the time is annoying, and I mostly reuse the loading and error view anyways
- I have to manually type data, and specify that it is
NonNullable
So I wanted to rethink and redesign this pattern a bit to be more reusable and elegant.
HOC
I know, I know, we do not want to go back to the days of wrapping your React components with multiple layers of higher order components - but just take a look at this:
drafts.tsx
const loader = createLoader(...); // somewhere
// Component.tsx
const Component = withLoader((props, data) => {
return <div>...</div>
}, loader);
Data-loading is one of the few cases where HOC make sense, in my opinion.
We want to be able to write the component as if the data has loaded already - I don't want to...
- 💩 Have lots of checks inside of the body to see if the data is defined
- 💩 Supply fallback values,
- 💩 Cast types
- 💩 Have lots of inline ternary loading states (
data ? (...) : null
)
The only way to achieve this is to simply await mounting the component until the data exists, and treat it more as a prop. Higher order components allow for this pattern.
Creating an initial design
I started working on a new design by using the old implementation as reference.
Lesson learned ✅
I found that defining terms, such as a "loader" and "consumer" helps people wrap their head around what is going on and how it all plays together.
drafts.tsx
// You create a "loader"
const loader = createLoader({
// it needs: queries, loading state, error state
queries: () => [useSomeQuery(), useSomeOtherQuery()] as const,
onLoading: () => <div>Loading...</div>,
onError: () => <div>Error!</div>,
});
// And then "consume" it
const Component = withLoader((props, data) => {
const [query1, query2] = data;
// ...
}, loader);
So this design solved atleast one of my initial issues: I no longer have to create a wrapper component. But it's still not feature complete, and after having used this API for a while I had annoyances with it.
- You can't pass arguments to the loader from the consumer
- I still have to manually declare loading/error states for every individual loader
- It's not obvious that the
queries
argument is a hook - Adding
as const
is tedious and easy to forget.
Passing arguments
This was simple enough, I just needed some way to transform the props of the consumer into arguments for the loader. So let us add that to createLoader
:
examples/args.tsx
type ComponentProps = {
userId: string;
};
const loader = createLoader({
queriesArg: (props: ComponentProps) => props.userId,
// `queries` -> `useQueries` for clarity
useQueries: (userId) => [useGetUser(userId)] as const,
});
There you go, we now have a way for loaders to utilize their consumers' props inside the loader.
We have also hinted at the fact that the queries
argument is a loader by renaming it to useQueries
.
Extending existing loaders
Now this was a really challenging (and interesting) Typescript task that taught me a lot about generics, but after a lot of work and debugging - I was able to implement the feature while maintaining the strong types.
examples/extend.tsx
const baseLoader = createLoader({
onLoading: () => <div>...</div>,
onError: () => <div>...</div>,
});
const userLoader = baseLoader.extend({
useQueries: () => [useSomeQuery()] as const,
}); // no need to add anything else!
This allows for a very nice pattern where you create a base loader with default loading and error states, and extend from that. This will more easily allow you to configure all loaders at once as well - and reuse and extend existing loaders.
In my own experience, I have set up a baseLoader
, as well as a baseRouteLoader
and baseDialogLoader
(both extended from baseLoader
). You can set it up however you want.
Rethinking some of the API
At this point, I was about 4 months and 4 versions into writing the package. I felt it was time for a major update. This also meant that I could take a look back at the interface and fix things that were bothering me. Notably, having to specify as const
after the useQueries
argument.
I solved this by returning an object instead of an array. I also implemented deferredQueries
as an optional way of returning queries from the loader - hinting that the queries should not block the consumer from loading.
examples/v1.tsx
const loader = baseLoader.extend({
useQueries: () => ({
queries: {
user: useGetUserQuery(),
},
deferredQueries: {
extraData: useGetGiantDataset(),
},
payload: {
any: {
arbitrary: "data",
},
},
}),
});
Now this is starting to look more feature complete. Adding payload
as a way to pass anything from the loader to the consumer opens up the possibility for stateful and controlled loaders as well. 🔥
Maintaining the package
When dealing with component loading, it is kind of critical that you don't push bugs. For this reason, I ended up writing a test suite of about 30 tests that verify that everything is working correctly in the package.
I also wrote extensive documentation by using Docusaurus. I'm very pleased with how that turned out, and have had some nice feedback on it.
Final thoughts
Writing RTK Query Loader taught me a bunch about Typescript, package maintenance, open source and writing good documentation. I've also really enjoyed writing a cohesive API that solves a concrete issue that I cared about.
Do I still use the package?
- If I'm writing a single page application or doing data loading through the client, then yes, absolutely.
- If I'm using react server components or using some other suspense enabled framework... probably not.