Zustand Fetching Helpers
Here are some helper functions for working with zustand.
The creators of zustand did a great job and we really enjoy using this state manager.
What is this library for? We always have standard requests to the backend, so we offer several methods to simplify the
work with requests using zustand. All examples are made using TypeScript
Problem: All asynchronous requests are actually very similar, but we are constantly faced with our own
implementation from different developers for each request. This makes it difficult to understanding and easy to miss
something. We present you a way to remove the burden of request's infrastructure and leave only control over fetching.
Request
Here's what the simplest queries might look like.
export const useUser = create<IUserState>((set, get) => ({
...createSlice(set, get, "userRequest", async (id: string) => {
return getUserById(id);
}),
}))
When you read the zustand documentation, you see that it recommends using
slice-pattern.
We have made a special helper for these purposes.
Let's imagine that you have a request that you want to execute. createSlice is a special method that will
automatically create all the necessary environment for working according to our description. For example, a request for
information about a user. Describe our store.
interface IUserState {
userRequest: ICreateRequest<string, IUser>
}
ICreateRequest - interface that shows other developers and you that here used a helper to create a request.
export type ICreateRequest<Payload, Result> = {
action: (params: Payload) => void;
atom: ContentLoading<Result, Payload>;
clear: () => void;
abort: () => void;
setAtom: (value: Partial<Result>, rewrite?: boolean) => void;
};
- action - function to call our request.
- atom - request store. ContentLoading indicates that this is loading data
- clear - function to clear the atom field.
- abort - function to abort the request. Useful in case we leave the page where the request was called before the
end of the request.
- setAtom - set content field in our atom. You can use setAtom in the same way like zustand set.
The atom field from ICreateRequest is the ContentLoading interface.
export interface ContentLoading<Content, Payload = undefined> {
content: Content | null;
status: ILoadingStatus;
payload?: Payload | null;
error?: any;
lastFetchTime: Date | null;
}
Thanks to ContentLoading, we do not need to declare the request status separately, create a field to store the
request execution error, etc. We can always get the content of the request and display it inside the component,
show the process or the error.
- content - the data returned by request. null - when we haven't received anything yet
- status - the status of our request. Possible values: "init", "loading", "loaded", "waiting", "progress", "
error"
- payload - our request's payload
- error - the error returned by the request
- lastFetchTime - Date of last fulfilled request
Congratulations, we have reviewed the interface of our createSlice.
Let's create a simple store with request to get information about the user.
Please note that the name passed to createSlice must match what you defined in IUserState.
export const useUser = create<IUserState>((set, get) => ({
...createSlice(set, get, "userRequest", async (id: string) => {
return getUserById(id);
}),
}))
Thus, we created a request for get user data. getUserById is your data request, which should return the type IUser
.
That's all. 3 lines to describe our request. What can we do with it now? let's see. We used a small StatusSwitcher
component to keep the example component code cleaner.
export const User = ({ id }: { id: string }) => {
const { atom, action } = useUser((state) => state.userRequest);
useEffect(() => {
action(id);
}, [action, id])
return (
<div>
<StatusSwitcher status={atom.status} error={atom.error.message}>
User name: <b>{atom.content?.name}</b> // we will see it when loading will be done
</StatusSwitcher>
</div>
);
};
interface ISwitcherProps {
status: ILoadingStatus;
children: ReactNode;
error: string
}
const StatusSwitcher = ({ status, children, error }: ISwitcherProps) => {
return <>
{status === "loaded" && <>{children}</>}
{status === "loading" && <>loading...</>}
{status === "error" && <>{error}</>}
</>
}
What we got:
- we always know the status of the request
- we can get request data or its execution error
- we have a simple way to call a request
- we needed a minimum of type descriptions and fields in our store
You also can add any data processing to your request, use your own baseFetch handlers or some solutions. The main
thing is that the returned result must match the type you declared in userRequest: ICreateRequest<string, IUser>
. For example, let's process the result of a query
export const useUser = create<IUserState>((set, get) => ({
...createSlice(set, get, "userRequest", async (id: string) => {
const result = await getUserById(id);
return { ...result.data, role: "artist" }
}),
}))
But that's not all, createSlice has much more powerful functionality. Here is an advanced description of the
parameters of createSlice(set, get, name, payloadCreator, extra)
- set and get are methods from our zustand store
- name - the name of our request. Please note that it must match the one you defined in the type
your store
- payloadCreator is the function inside which we execute our request. Important payloadCreator has an object
with field
signal: AbortSignal
as the second argument. It can be passed to your fetch request and
calling the userRequest.abort
method will cancel the request.
Thus, you can edit the previous example using abort
const { action, abort } = useUser((state) => state.userRequest);
useEffect(() => {
action("id");
return () => {
abort()
}
}, [action])
Then we pass signal to our request. More about how to use the signal
in fetch
export const useUser = create<IUserState>((set, get) => ({
...createSlice(set, get, "userRequest", async (id: string, { signal }) => {
return getUserById(id, signal);
}),
}))
- extra is an object that allows you to take full control over the execution of the request and do magic.
All fields can be conditionally divided into 3 groups: fields of an atom, reactions and a reducer.
- initialStatus - the status that can be defined for our request by default. Default value is "
loading", but you can define some of LoadingStatus. Indeed, a lot depends on the status in our interface and we do
not always need loading.
- initialContent - content of the atom field. The default is defined as null until a value is returned from
the request.
Update our userRequest and use these fields
export const useUser = create<IUserState>((set, get) => ({
...createSlice(set, get, "userRequest", async (id: string, { signal }) => {
return getUserById(id, signal);
}, { initialStatus: "init", initialContent: { name: "John Doe" } }),
}))
The following fields are reactions that are called for your request lifecycle. Very useful for alerts.
- fulfilledReaction - called when request was successful
- rejectedReaction - called when request was rejected
- resolvedReaction - called after the request is executed, regardless of the result
- actionReaction - called before the start of the request
- abortReaction - called when request was aborted
export const useUser = create<IUserState>((set, get) => ({
...createSlice(set, get, "userRequest", async (id: string, { signal }) => {
return getUserById(id, signal);
}, {
fulfilledReaction: (result: IUser, id: string) => {
scheduleRequest.action(id)
},
rejectedReaction: () => {
notification.error("Some error was accured")
},
actionReaction: (id: string) => {
log("Log user", id)
}
}),
}))
And the last field is contentReducers.
- contentReducers - with it we can fully manage the data we put in content.
There are 4 fields in total pending, fulfilled, rejected, aborted. Each of these functions will be called at
its own stage of the request execution.
What is it for? For example, we want to replace query data. This is very useful because we don't need to write a lot of
logic in the slice request body.
export const useUser = create<IUserState>((set, get) => ({
...createSlice(set, get, "userRequest", async (id: string, { signal }) => {
return getUserById(id, signal);
}, {
contentReducers: {
pending: () => ({})
},
}),
}))
We previously made a request to get a schedule for a user as an example. Let's update the IUserState that will
execute the request to get information about the user and his schedule.
interface IUserState {
userRequest: ICreateRequest<string, IUser>
scheduleRequest: ICreateRequest<string, ISchedule>
}
export const useUser = create<IUserState>((set, get) => ({
...createSlice(set, get, "userRequest", async (id: string) => {
return getUserById(id);
}),
...createSlice(set, get, "scheduleRequest", async (id: string) => {
return getScheduleById(id);
}),
}))
We have described one more request. The user's schedule will be called if we get the user's data. But you can use
and call request.action in any order you want.
GroupRequest
Sometimes we have a situation where we need to execute a series of single requests asynchronously. For example, we have
a list of users, and we want to request the schedule of each of them only in the case when the user clicks on the
button. To do this, the helper for executing group queries createGroupSlice will help us.
Update the store in accordance with the new requirements
interface IScheduleSlice {
schedulesRequest: ICreateGroupRequests<string, ISchedule>;
}
export const scheduleSlice = <T extends IScheduleSlice>(
set: SetState<T>,
get: GetState<T>,
): IUserSlice => ({
...createGroupSlice(set, get, "schedulesRequest", async ({ payload, key }: IGroupRequestParams<string>) => {
return getScheduleById(payload);
}),
});
Then our user component will look like this
export const User = ({ id }: { id: string }) => {
const { atom, action } = useCommon((state) => state.userRequest);
const call = useCommon((state) => state.schedulesRequest.call);
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
action(id);
}, [action, id])
return (
<div>
User name: <b>{atom.content?.name}</b>
<button onClick={() => setIsOpen(true)}>get schedule</button>
// open user's schedule
{isOpen ? <Schedule id={id} /> : <></>}
</div>
);
};
const Schedule = ({ id }: { id: string }) => {
const { status, content, error } = useCommon((state) => state.schedulesRequest.get(id), shallow);
useEffect(() => {
call([{ key: id, payload: id }])
}, [action, id])
return <StatusSwitcher status={status} error={error}> //use StatusSwitcher component again
Shedule: {content.days} //for example, show some scheduled days
</StatusSwitcher>
}
After the request is fulfilled, the schedule continues to be stored. For example, can be reused without the need to
execute the
request again.
The list of fields that createGroupSlice accepts is identical to createSlice. The difference is what
creates createGroupSlice
- requests - an object that contains all our requests. The queries are identical to those returned by **
createSlice**. Usually we don't need to interact with it. The keys in the object are the key that you passed to
the call function. Be careful that these values should be unique!
- call - a function that accepts an array of objects of the IGroupRequestParams type. This means that requests
will be called with the payload that we passed in and a unique key.
export interface IGroupRequestParams<Payload> {
key: string;
payload: Payload;
}
- get - function that has an optional key parameter. It will return the request by key or all requests if _
key_ is undefined.
- getContent - will return the content of the selected request by key. It is necessary in a situation when we
work only with request data.
- clear - has an optional key parameter. Clears whole store or only the selected query by key.
Now we have the ability to create a dynamic number of requests of the same type without the need to describe them in
your store.
Modal window
createModal - a helper for creating everything you need to work with a modal window. It happens often that we need
to call a modal window from different components and pass data to it (for example, open a modal window to delete entity
by id).
interface IUserState {
userListRequest: ICreateRequest<void, IUser[]>
removeModal: IModalCreator<{ id: string }>;
}
export const useUser = create<IUserState>((set, get) => ({
...createModal(set, get, "removeModal", { id: "" }),
...createSlice(set, get, "userListRequest", async () => {
return getUserList();
}),
}))
The first three arguments to createModal are the same as those of createSlice, and the last is the modal's storage
value. If it is not needed, then you can simply write undefined.
const Page = () => {
const { content } = useUser((state) => state.userListRequest.content || [])
return <>
{content.map(user => <User key={user.id} user={user} />)}
<RemoveModal /> // we can put our modal in the root of page
</>
}
export const User = ({ user }: { user: IUser }) => {
const { open } = useUser((state) => state.removeModal);
return (
<div>
User name: {user.name}
<button onClick={() => open({ id: user.id })}>remove</button>
</div>
);
};
export const Modal = () => {
const { atom, close } = useUser((state) => state.removeModal);
const action = useUser((state) => state.removeRequest.action);
const handleRemove = usecallback(() => {
action(atom.data.id)
}, [])
return (
<Modal isOpen={atom.isOpen}>
<button onClick={close}>cancel</button>
<button onClick={handleRemove}>confirm</button>
</Modal>
);
};
Now we don't have to worry about forwarding the necessary props or declaring the state of the modal somewhere.
//todo work in progress