Call actions within actions, in a typesafe way? #750
-
I've been using zustand very happily for over a year now, but have frequently run into the problem which I will now describe. It's a very common pattern in my usage to need to call an action from within another action. As a TypeScript user, the main issue is that the (very well-written) typings from Here is an example. It's contrived, but demonstrates the issue: import axios from 'axios'
import create, { GetState, SetState } from 'zustand'
import { combine } from 'zustand/middleware'
export type UserStore = {
data: null | {
classes?: string[]
students?: string[]
userType: 'teacher' | 'student'
}
}
export const userStoreInitialState = (): UserStore => ({
data: null,
})
const userStoreActions = (
set: SetState<UserStore>,
get: GetState<UserStore>,
) => ({
fetchClasses: async () => {
set({
data: {
classes: (await axios('/my-backend/user-type')).data,
userType: 'student',
},
})
},
fetchStudents: async () => {
set({
data: {
students: (await axios('/my-backend/user-type')).data,
userType: 'teacher',
},
})
},
fetchUserType: async () => {
set({
data: {
userType: (await axios('/my-backend/user-type')).data,
},
})
},
initialize: async () => {
const {
fetchUserType,
fetchClasses,
fetchStudents,
} =
// TODO: Solve problem of calling actions within actions in a typesafe way
get() as any
await fetchUserType()
const userType = get().data?.userType
if (userType === 'teacher') {
fetchStudents()
} else if (userType === 'student') {
fetchClasses()
}
},
})
export const useUserStore = create(
combine(
userStoreInitialState(),
userStoreActions,
),
) So, we want to be able to call other actions, like I'd be very grateful if anyone has any non-hacky suggestions as to how to make this work in a typesafe way. Thanks! |
Beta Was this translation helpful? Give feedback.
Replies: 5 comments 20 replies
-
Yeah, |
Beta Was this translation helpful? Give feedback.
-
I only started out yesterday with Zustand, so forgive me if this is not good practice. I would like to use Immer and also plan to always declare my actions outside of the store so that all my actions actions
I created a base store which only has a // TODO: Using NamedSet her as the 3rd parameter "name" is not defined on SetState
export const makeImmutableSetter =
<Store extends object>(set: NamedSet<Store>): Setter<Store> =>
(fn, name?: string) =>
set(produce(fn), false, name); I then created a utility function which merges the base store with the actual store that I want to create (one with export const getBaseStore = <T extends object>(set: SetState<T>): BaseStore<T> => ({
set: makeImmutableSetter(set),
});
export const createVanillaStoreWithDevtoolsFromBase = <T extends object>(
getStore: (set: SetState<T & BaseStore<T>>, get?: GetState<T & BaseStore<T>>) => T,
name: string
) =>
createVanillaStore<T & BaseStore<T>>(
devtools(
(set: SetState<T & BaseStore<T>>, get?: GetState<T & BaseStore<T>>) => ({
...getBaseStore(set),
...getStore(set, get),
}),
{ anonymousActionType: name }
)
); and this is what my store looks like: import createHookFromStore from 'zustand';
import { makeActionType, createVanillaStoreWithDevtoolsFromBase } from '@stores/utils';
export const actionType = makeActionType('appStore');
export interface AppStore {
shouldShowPreloader: boolean;
}
export const appStore = createVanillaStoreWithDevtoolsFromBase<AppStore>(
(set, get) => ({
shouldShowPreloader: false,
}),
'appStore'
);
export const useAppStore = createHookFromStore<AppStore>(appStore); I then have a separate actions file for all my actions where I use the custom `set() function of the base store (using Immer) to set the state. import { actionType, appStore, AppStore } from './app.store';
export const setImmutableState = appStore.getState().set;
export const showPreloader = () => {
setImmutableState((state) => {
state.shouldShowPreloader = true
}, actionType('showPreloader'));
};
export const hidePreloader = () => {
setImmutableState((state) => {
state.shouldShowPreloader = false
}, actionType('hidePreloader'));
};
export const fetchStuff = async () => {
showPreloader()
const response = await fetchStuff()
hidePreloader()
doOtherStuff()
if (response.data) {
setImmutableState((state) => {
state.stuff = response.data;
}, actionType('fetchStuff'));
}
} It seems to be working ok from my limited experience and everything is nicely decoupled and I can call actions from within actions. But perhaps there are better solutions? |
Beta Was this translation helpful? Give feedback.
-
A class can be used: import { create, StoreApi } from "zustand";
import { combine } from "zustand/middleware";
type Api = StoreApi<CounterState>;
type CounterState = { value: number };
class CounterActions {
constructor(private set: Api["setState"], private get: Api["getState"]) {}
add = (n: number) => this.set(state => ({ value: state.value + n }));
increment = () => this.add(+1);
decrement = () => this.add(-1);
}
const initialState: CounterState = { value: 0 };
export const useCounterStore = create(combine(initialState, (set, get) => new CounterActions(set, get)));
// Usage
const value = useCounterStore(s => s.value);
const increment = useCounterStore(s => s.increment); |
Beta Was this translation helpful? Give feedback.
-
@tokland Oooo that's very clever. Classes can be used to create circular type dependencies. In fact now you don't even need import create, { StoreApi as Store, Mutate } from "zustand"
class MyStore {
counter = 0
private add = (x: number) => {
this.set(s => ({ counter: s.counter + x }))
}
increment = () => {
// here `this` has all the actions typed (and state too ofc), exactly what the OP wanted.
this.add(+1)
}
decrement = () => {
this.add(-1)
}
constructor(
private set: Store<MyStore>["setState"],
private get: Store<MyStore>["getState"],
private store: Mutate<Store<MyStore>, [/* add middleware mutators here */]>
) {}
}
let useMyStore = create<MyStore>()((set, get, store) => new MyStore(set, get, store)) To elaborate on "Classes can be used to create circular type dependencies"... What I mean is you can't do this... import { StoreApi as Store } from "zustand";
const createMyStore = (set: Store<ReturnType<typeof createMyStore>>["setState"]) => {
let counter = 0
let add = (x: number) => set(s => ({ counter: s.counter + x }))
let increment = () => add(1)
let decrement = () => add(-1)
return { counter, increment, decrement }
}
// Compilation error
// 'createMyStore' implicitly has type 'any' because it does not have a type annotation and is referenced
// directly or indirectly in its own initializer. That is to say you can't type the parameter of a function using what it returns, but if you make that function a class, then you can do it! import { StoreApi as Store } from "zustand";
class MyStore {
constructor(private set: Store<MyStore>["setState"]) {}
counter = 0
private add = (x: number) => this.set(s => ({ counter: s.counter + x }))
increment = () => this.add(1)
decrement = () => this.add(-1)
} |
Beta Was this translation helpful? Give feedback.
-
So essentially, a lot of legwork for something that should just work?
I'm creating the store like this:
Trying to let typescript do the maximum type inference as I couldn't figure out how to type set and get appropriately, but get is definitely typed wrong from within IMO SideNote:
works but you know... not ideal! |
Beta Was this translation helpful? Give feedback.
I only started out yesterday with Zustand, so forgive me if this is not good practice. I would like to use Immer and also plan to always declare my actions outside of the store so that all my actions actions
const increasePopulation = useStore(state => state.increasePopulation)
useStore.getState().myAction
(I don't have to reference my store anywhere except in the actions file)I created a base store which only has a
set()
function, which basically just acts as a proxy for Immer'sproduce()
method.// TODO: Using NamedSet her as the 3…