-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
How to extract common logic and reuse it in multiple store #161
Comments
store logic in zustand is just a function. So, you could define a common function. There's also middleware. Do you have any suggestion? Is the example something like this? const useStore = create(set => {
result: null,
loading: false,
error: null,
fetch: async url => {
try {
set({ loading: true })
const result = await (await fetch(url)).json()
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
}); |
In a real-wrold App, there will be many stores manage their loading state: // This is the store for Page1
const usePageModel1 = create(set => {
result: null,
loading: false,
error: null,
fetchData1: async (id)=> {
try {
set({ loading: true })
const result = await (await fetch(`/api1/${id}`)).json() // load data from api1
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
});
const Page1 = () => {
const model = usePageModel1();
// ...
}
// This is the store for Page2
const usePageModel2 = create(set => {
result: null,
loading: false,
error: null,
fetchData2: async (id)=> {
try {
set({ loading: true })
const result = await (await fetch(`/api2/${id}`)).json() // load data from api2
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
});
const Page2 = () => {
const model = usePageModel2();
// ...
} There is clearly some duplicate code between these two models. So I wonder how to extract this logic and reuse it. @dai-shi |
How about this? const createFetchStore = (fetchActionName, baseUrl) => create(set => {
result: null,
loading: false,
error: null,
[fetchActionName]: async (id)=> {
try {
set({ loading: true })
const result = await (await fetch(`${baseUrl}/${id}`)).json()
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
}); |
Cool. So the key to code reuse is store factory function: // factory
const createFetchStore = (baseUrl) => create(set => {
result: null,
loading: false,
error: null,
fetch: async (id)=> {
try {
set({ loading: true })
const result = await (await fetch(`${baseUrl}/${id}`)).json()
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
});
// store for Page1
const usePageStore1 = createFetchStore('/api1/');
const Page1 = () => {
const model = usePageStore1();
// ...
}
// store for Page2
const usePageStore2 = createFetchStore('/api2/');
const Page2 = () => {
const model = usePageStore2();
// ...
} Cool. It is cleaner than the redux way. But I am looking for a composable way to use zustand. In the world of react hooks, we can extract the fetching logic, use it to fetch from multiple API, an compose them together, into one hook: import useSWR from 'swr'
function useCombinedData() {
const { data: events, error, isValidating } = useSWR('/api/events')
const { data: projects, error, isValidating } = useSWR('/api/projects')
const { data: user, error, isValidating } = useSWR('/api/user', { refreshInterval: 0 }) // don't refresh
// combine these data...
return combinedData;
} The most composable way to use zustand, seems to be "compose multiple useStore into one hook": const useStore1 = create(set => {/* ... */});
const useStore2 = create(set => {/* ... */});
function useCombinedData() {
const { data1, reducer1 } = useStore1();
const { data2, reducer2 } = useStore2();
// combine these data...
return combinedData;
} |
Did it work? Great. Here's another one. (I didn't run it.) const createFetchState = baseUrl => set => ({
result: null,
loading: false,
error: null,
fetch: async (id)=> {
try {
set({ loading: true })
const result = await (await fetch(`${baseUrl}/${id}`)).json()
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
});
// store for Page1
const usePageStore1 = create(set => ({
otherState: 'foo',
...createFetchState('/api1')(set),
});
const Page1 = () => {
const model = usePageStore1();
// ...
} |
So the other way to reuse store logic is call other const createFetchState = baseUrl => set => ({
result: null,
loading: false,
error: null,
fetch: async (id)=> {
try {
set({ loading: true })
const result = await (await fetch(`${baseUrl}/${id}`)).json()
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
});
// store for Page1
const usePageStore = create(set => {
const api1FetchState = createFetchState('/api1')(set)
const api2FetchState = createFetchState('/api2')(set)
// try to combine the fetch of these two store
const fetch = async (id) => {
await api1FetchState.fetch(id); // it will set the store implicitly :(
await api2FetchState.fetch(id); // and the field keys will conflict
}
// How to combine the result of these two store?
// const result = [...api1FetchState.result, ...api2FetchState.result] // it doesn't works
// what should I return here?
});
const Page1 = () => {
const model = usePageStore();
// ...
} |
Ah, so you want to reuse the common logic in one store. We'd need some kind of namespace then. const scopedSet = (ns, set) => (update) => {
set(prev => ({
[ns]: { ...prev[ns], typeof update === 'function' ? update(prev[ns]) : update },
})
}
const usePageStore = create(set => ({
data1: createFetchState('/api1')(scopedSet('data1', set)),
data2: createFetchState('/api2')(scopedSet('data2', set)),
}) and, then combining fetch actions... const usePageStore = create((set, get) => ({
data1: createFetchState('/api1')(scopedSet('data1', set)),
data2: createFetchState('/api2')(scopedSet('data2', set)),
fetch: async (id) => {
await get().data1.fetch(id)
await get().data2.fetch(id)
},
}) You can also define Just an idea. Not sure this is an optimal solution. There can be a better one. |
FYI, we had had a slightly related discussion in #163. |
scopedSet + immer looks perfect to me! I got some typescript issue, zustand can't infer the |
@csr632 Any updates? |
@dai-shi |
If it's about ts friendliness, maybe, we should open a new issue for discussion then, instead of using the old issue, and close this issue? |
Or, maybe just close this and reopen the other one because it has good discussion there. |
I'd like to add to this solution that it's not always an API call that requires you to keep track of the loading state. For ex: const useWalletStore = create(set => {
result: null,
loading: false,
error: null,
fetch: async url => {
try {
set({ loading: true })
const result = await MyWalletService.generateWallet();
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
});
import ChartJS from "chart-js";
const useChartStore = create(set => {
result: null,
loading: false,
error: null,
fetch: async url => {
try {
set({ loading: true })
const result = await MyWalletService.getBalance();
const chartData = await ChartJS.generate(result); // this is dummy code, just as an example
set({ loading: false, chartData })
} catch(error) {
set({ loading: false, error })
}
},
}); in this example, you see the usage of different services that each use async operations, but there is no way to remove the duplicated code of the loading state. It'd be nice if there was some kind of middleware to automatically add the loading and error property and set the loading state before/after the action is done or if it fails, to set the error property and loading state as well. |
But, then you could accept custom const createFetchStore = (fetchActionName, fetchFunc) => create(set => {
result: null,
loading: false,
error: null,
[fetchActionName]: async (id)=> {
try {
set({ loading: true })
const result = await fetchFunc(id)
set({ loading: false, result })
} catch(error) {
set({ loading: false, error })
}
},
}); You could extract the logic in another way. Someone can try it as middleware. |
This would mean you'd have to pass the service name, fetchFunc, or whatever you're calling from your react component, but there are a couple of problems with that:
A middleware would be the way to go IMO. |
Yeah, middleware is hard for typing. I'd avoid it if possible. A store create helper would be easier. Just in case, your example would like this with the helper: const useWalletStore = createFetchStore('fetch', async (url) => {
const result = await MyWalletService.generateWallet();
return result;
});
import ChartJS from "chart-js";
const useChartStore = createFetchStore('fetch', async (url) => {
const result = await MyWalletService.getBalance();
const chartData = await ChartJS.generate(result); // this is dummy code, just as an example
return chartData;
}); |
I get your approach and it's a good one, but I still miss a piece of the puzzle. For example (no actual code, just made something up): const useSettingStore = create(set => {
currency: "EURO",
decimales: 2,
hidden: false,
setCurrency: newCurrency => set({ currency: newCurrency }),
.....
});
const useChartStore = createFetchStore('fetchChartData', async (decimals, currency, hidden, account, tokens, balances) => {
if(hidden) return "****"; // <-- Cannot get this setting using get().hidden;
return ChartService.getChart(decimals, currency, account, tokens, balances); // <-- Only way to get the params is by passing it down...
});
// .. other async stores made by createFetchStore (useTokenStore, useBalancesStore, useAccountsStore, ....)
// Cluttered React Component
const WalletComponent = () => {
const { currency, decimals, hidden } = useSettingStore(); // Settings store
const { tokens } = useTokensStore(); // async action store create through "createFetchStore"
const { balances } = useBalancesStore(); // async action store create through "createFetchStore"
const { account } = useAccountStore(); // async action store create through "createFetchStore"
const { chartData, fetchChartData } = useChartStore(); // async action store create through "createFetchStore"
// Any other "async" variable needed (created using createFetchStore) would add a line as they are individual stores
fetchChartData(decimals, currency, hidden, account, tokens, balances);
} In the example above, you cannot use Of course, this isn't a big problem in a small application, but it does become a problem when you're at a point where you have to pass down 1-3 of these values (Currency, Language, Network, any kind of setting, account data, or other metadata...) to each action you're calling, which clutters the codes massively in basically ALL of the components where you need some kind of data. I've also tried to create a middleware for this issue, but I figured out that middlewares are just hooks to perform before and/or after the |
In general, I would recommend to create a single store unless multiple stores are totally isolated. In a single store scenario, if we need to extract common logic, we might extract store creator functions partially. const createFetcher = (prefix, fetchFunc) => (set, get) => ({
[prefix + 'result']: null,
[prefix + 'loading']: false,
[prefix + 'error']: null,
[prefix + 'fetch']: async (arg)=> {
try {
set({ [prefix + 'loading']: true })
const result = await fetchFunc(arg, { set, get })
set({ [prefix + 'loading']: false, [prefix + 'result']: result })
} catch(error) {
set({ [prefix + 'loading']: false, [prefix + 'error']: error })
}
},
});
const fooFetcher = ('foo', async (id, { set, get }) => { ... });
const barFetcher = ('bar', async (id, { set, get }) => { ... });
const useStore = create((set, get) => ({
...fooFetcher(set, get),
...barFetcher(set, get),
})) (Note: I never actually tried this approach...) |
Nice! That should get the trick done I guess. I currently have this: import create, { GetState, SetState, State } from "zustand";
// Not sure how to type fetchFunc ---------------------------V-------V
const createFetcher = (prefix: string, fetchFunc: (...args: any) => any) => (
set: SetState<State>,
get: GetState<State>
) => ({
[`${prefix}result`]: null,
[`${prefix}loading`]: false,
[`${prefix}error`]: null,
[`${prefix}fetch`]: async (arg: any) => { // <----------- not sure how to type this
try {
set({ [`${prefix}loading`]: true });
const result = await fetchFunc(arg, { set, get });
set({ [`${prefix}loading`]: false, [`${prefix}result`]: result });
} catch (error) {
set({ [`${prefix}loading`]: false, [`${prefix}error`]: error });
}
}
});
const fooFetcher = createFetcher("foo", async (id, { set, get }) => {
console.log("Calling fooFetcher...");
return { foo: "fetcher" }; // returned value should set the correct typing of this function's return type
});
export const useStore = create((set, get) => ({
...fooFetcher(set, get)
}));
// React Component
const MyComponent = () => {
const store = useStore();
store.xxxx // <------- no typing/autocomplete
} I'm only familiar with basic usage, not with these tricky typings. |
Oh, yeah, typing would be hard. Let me try. const createFetcher = <Prefix extends string, Arg, Result>(
prefix: Prefix,
fetchFunc: (
arg: Arg,
opts: { set: SetState<State>; get: GetState<State> }
) => Promise<Result>
) => (set: SetState<State>, get: GetState<State>) => {
type PrefixResult = `${Prefix}result`;
type PrefixLoading = `${Prefix}loading`;
type PrefixError = `${Prefix}error`;
type PrefixFetch = `${Prefix}fetch`;
return {
[`${prefix}result`]: null,
[`${prefix}loading`]: false,
[`${prefix}error`]: null,
[`${prefix}fetch`]: async (arg: Arg) => {
try {
set({ [`${prefix}loading`]: true });
const result = await fetchFunc(arg, { set, get });
set({ [`${prefix}loading`]: false, [`${prefix}result`]: result });
} catch (error) {
set({ [`${prefix}loading`]: false, [`${prefix}error`]: error });
}
}
} as Record<PrefixResult, Result | null> &
Record<PrefixLoading, boolean> &
Record<PrefixError, Error | null> &
Record<PrefixFetch, (arg: Arg) => Promise<void>>;
};
const fooFetcher = createFetcher("foo", async (id: number, { set, get }) => {
console.log("Calling fooFetcher...");
return { foo: "fetcher" };
}); I may prefer flat |
That works! If I use the I'll see if I can figure out how it's done, thanks for all the help already! const fooFetcher = createFetcher("foo", async (id: number, set, get) => {
console.log("Calling fooFetcher...");
set({ foo: "fetcher" }); // no type checking/autocomplete
get().x; // no typing/autocomplete
return { foo: "fetcher" };
});
// useStore loses all typings
export const useStore = create((set, get) => ({
...fooFetcher(set, get)
})); |
Did some adjustments, got this far: Typescript Playground |
I have two stores in my project that share same logics. I want to reuse them and add little unique logic to each of them. Thanks to Dai-Shi's reply, I can do it like this: interface ItemWithID {
id: number;
}
export interface ListStoreState<T> {
items: T[],
appendItems: (items: T[]) => void;
// other common logic
}
export const createListStoreLogic = <T extends ItemWithID>() => (
set: StoreApi<ListStoreState<T>>['setState'],
get: StoreApi<ListStoreState<T>>["getState"]
) => ({
appendItems: (items: T[]) => {
set(state => {
const newList = [ ...state.items, ...items ];
return { items: newList }
});
},
})
// Actual store:
interface FolderStoreState extends ListStoreState<Folder> {
// unique states and logics
}
export const useFolderStore = create<FolderStoreState>((set, get) => ({
items: [],
...createListStoreLogic<Folder>()(set, get),
})); Hope this could be useful. |
How to extract common logic from store and reuse it in multiple store (or for multiple times)?
For example, we often need to track the loading state and error state of requsts. How to reuse this logic?
Here is some suggestion from redux community. In the react hook world, we have swr.
If zustand have solution for this, we should put it in the documentation!
The text was updated successfully, but these errors were encountered: