A Store Mimicing the Slice in Redux Toolkit
In an ordinary redux slice we need to define the state, the reducers, and the extra reducers for successful thunk actions.
Because API-calls will be delegated to react-query, for zustand it remains to define a store that contains
- state
- setters (a replacement of reducers)
whether to store the fetched data from react-query into zustand store depends on
- how often we need the most updated data and
- how complicated are the data processing of the fetched data.
For most cases we can keep putting the data into zustand store for debug purpose. We make direct use of the { data }
part from react-query quen we need caching or prefetched data.
1import { create } from 'zustand' 2import { persist } from "zustand/middleware" 3import { immer } from 'zustand/middleware/immer' 4 5interface AuthState { 6 accessToken: string 7 clickToLogin: () => void 8} 9 10interface AuthSetters { 11 setAccessToken: (token: string) => void 12 setClickToLogin: (fn: () => void) => void 13} 14 15const initialState: AuthState = { 16 accessToken: "", 17 clickToLogin: () => { } 18}
Note that here we also store functions into the store. As in redux we need to be careful when we try to persist data into localStorage
:
19export default create<{ state: AuthState, setters: AuthSetters }>()( 20 persist(immer(set => ({ 21 state: initialState, 22 setters: { 23 setAccessToken: token => { 24 set(store => { 25 store.state.accessToken = token; 26 }) 27 }, 28 setClickToLogin: (fn: () => void) => { 29 set(store => { 30 store.state.clickToLogin = fn 31 }) 32 } 33 } 34 })), 35 { 36 name: "auth-storage", 37 partialize: (store) => ({ 38 state: { accessToken: store.state.accessToken } 39 }), 40 // omit the following when our persisted data are on the top level of key-value pairs 41 merge: (persistedState, currentState) => { 42 const typedPersistedState = persistedState as { state?: Partial<AuthState> } 43 return { 44 ...currentState, 45 state: { 46 ...currentState.state, 47 ...(typedPersistedState?.state || {}) 48 } 49 } 50 } 51 } 52 ) 53)
We set a whitelist of persisted data by partialize
.
When nested property is needed (which is our case as we separate our state by state
and setters
for clarity), we explain how to merge the persisted state and the initial state.
Axios Instance that uses the Token in Request Interceptor
By default a store created from zustand has an API to get the state outside of any react component (on the other hand we need to get the state of a redux-store in an hacky way).
1import axios from "axios"; 2import getEnv from "../src/util/getEnv"; 3import useAuthStore from "../src/store/useAuthStore"; 4 5const billieInfoClient = axios.create({ 6 baseURL: getEnv().VITE_BILLIE_INFO_BACKEND_ORIGIN, 7 responseEncoding: "utf8", 8 headers: { 9 "Content-type": "application/json", 10 }, 11}); 12 13billieInfoClient.interceptors.request.use((req) => { 14 const token = useAuthStore.getState().state.accessToken; 15 if (token) { 16 req.headers["Authorization"] = "Bearer " + token; 17 } 18 return req; 19}); 20 21export default billieInfoClient;
with
// util/getEnv.ts const env = import.meta.env; export default () => { return env; };
in a vite react-app application.
API Call
We will use sonner
and toast
from shadcn.
useQuery
1import billieInfoClient from "../../axios/billieInfoClient"; 2import { BillieConfig } from "../../../google-auth-backend/src/dto/dto"; 3import useBillieInfoStore from "@/store/useBillieInfoStore"; 4import apiRoutes from "../../axios/apiRoutes"; 5import { useQuery } from "@tanstack/react-query"; 6import queryKeys from "./queryKeys"; 7import { useEffect } from "react"; 8import { toast } from "sonner"; 9import useAuthStore from "@/store/useAuthStore"; 10 11export default () => { 12 const setBiillieInfo = useBillieInfoStore((s) => s.setters.setBiillieInfo); 13 const clickToLogin = useAuthStore((s) => s.state.clickToLogin); 14 const setAccessToken = useAuthStore((s) => s.setters.setAccessToken); 15 16 const query = useQuery({ 17 queryKey: queryKeys.TERRAFORM_CONFIG, 18 queryFn: async () => { 19 const res = 20 (await billieInfoClient.get) < 21 { 22 success: boolean, 23 result: { 24 dev: BillieConfig, 25 uat: BillieConfig, 26 prod: BillieConfig, 27 }, 28 } > 29 apiRoutes.BILLIE_INFOR_TERRAFORM_CONFIG; 30 if (res.data.result) { 31 setBiillieInfo(res.data.result); 32 } 33 return res.data; 34 }, 35 staleTime: 0, 36 gcTime: 0, 37 enabled: false 38 });
-
We choose to use
enabled = false
because we are not going to fetch data whenever a component gets (re)rerendered. -
We have further dicussion on
staleTime
andgcTime
in this article.
Do we store data into zustand?
The following useEffect
is optional (but requires consideration):
39 useEffect(() => { 40 if (query?.data?.success && query?.data?.result) { 41 setBiillieInfo(query.data.result); 42 } 43 }, [query.data]);
We can directly get the data by returning the query
to the component that needs it. But first consider the way to share the fetched data:
- Do we want to cache it?
- Do we want an API call of a hardly updated data everytime we need it?
- Do we want the latest data everytime a component get rerendered? (if we
useQuery
inside a component, it calls the api everytime thestaleTime
gets exceeded)
In our situation:
- As we don't need to refetch the data everytime a component gets rerendered, we simply put that data into zustand, and we put
{ enabled: false }
into the useQuery. - In case we always want latest data shown in the screen (when rerendered, or when on focused), we simply use the data from react query.
Error Handling
Finally we handle the error when needed:
45 useEffect(() => { 46 if (query.error || (query?.data?.success != null && !query.data.success)) { 47 setAccessToken(""); 48 toast("Please login again", { 49 action: { 50 label: "Click to Login", 51 onClick: () => { 52 clickToLogin(); 53 }, 54 }, 55 }); 56 } 57 }, [query.error]); 58 // in case the API always return 200 status code: 59 // }, [query.error, query?.data?.success]); 60 61 return query; 62};