Zustand store exhibits intermediate state flashes during async retry attempts
0 reputation · 12 Mar 2022, 20:48 UTC
0 reputation · 12 Mar 2022, 20:48 UTC
When an async action in a Zustand store retries on failure, multiple setState calls can be issued in quick succession. The goal is to guarantee that only the final successful attempt updates the store and that no intermediate state values are emitted to subscribers, preventing UI flicker.
Constraints include avoiding duplicate writes, handling concurrent retries that may race, and ensuring the solution does not rely solely on shallow equality checks that could miss deep changes. It is also unclear whether middleware can suppress stale setState calls before they reach the store, or if a queuing mechanism is required.
How can intermediate emissions be prevented during retry races? Should middleware discard outdated updates based on a retry identifier? Is a state‑level flag sufficient to block stale writes?
32775 reputation · 13 Mar 2022, 02:28 UTC
When a retryable async action in a Zustand store fires multiple set calls in quick succession, each call immediately triggers a render. The UI therefore briefly shows the provisional state (e.g., loading:true or a partial payload) before the final successful state arrives.
Most often the action dispatches a “retrying” status, then schedules the next attempt. Each attempt calls set again, producing a cascade of intermediate states. If the retry logic is written as a plain async/await loop or inside a useEffect, every iteration will push a new state onto the store.
set is synchronous and causes an immediate render.set calls that are not batched will each produce a separate UI update.batch middleware and Immer can coalesce consecutive updates into a single render.import { batch } from "zustand/middleware";
const useStore = create(batch((set, get) => ({
data: null,
loading: false,
setData: (payload) => set({ data: payload, loading: false }),
fetch: async () => {
set({ loading: true });
try {
const res = await fetchData();
set({ data: res, loading: false });
} catch (e) {
// retry logic
}
},
}))));const useStore = create((set) => ({
data: null,
loading: false,
retryId: 0,
fetch: async () => {
const currentId = Date.now();
set({ loading: true, retryId: currentId });
try {
const res = await fetchData();
set((state) => state.retryId === currentId ? { data: res, loading: false } : state);
} catch (e) {
// schedule next retry
set((state) => state.retryId === currentId ? { loading: true } : state);
}
},
}));loading:true flag inside the retry loop and instead expose a single isLoading derived from whether an ongoing request exists.set to log timestamps and payloads during a retry run. Confirm that only the final payload reaches consumers after batching or identifier checks.A flag like isRetrying only tells the UI that a retry is in progress; it does not suppress the intermediate set calls that cause flicker. The flag must be combined with either batching or a guard that discards stale updates.
To tailor the recommendation further, could you confirm whether your store already uses any batching middleware (e.g., immer or batch) or a persistence layer that might trigger additional renders?
Use comments to ask for clarification. Post a solution as an answer.
32,775 reputation · 13 Mar 2022, 02:35 UTC
If you attach a monotonically increasing retry token to each async attempt and store it in the slice, you can wrap Zustand’s set in a small middleware that compares the incoming token with the latest one stored. When the token is older, the middleware simply returns without calling the original setter, so no state change (and thus no render) occurs for stale retries.
import { create } from 'zustand'
function retryTokenMiddleware(config) {
return (set, get, api) => {
const wrappedSet = (partial, replace) => {
const state = get()
if (typeof partial === 'object' && partial !== null && 'retryToken' in partial) {
const incoming = partial.retryToken
if (incoming !== undefined && incoming < state.retryToken) {
// stale update – ignore
return
}
}
return set(partial, replace)
}
return config(wrappedSet, get, api)
}
}
const useStore = create()
(retryTokenMiddleware((set, get) => ({
data: null,
loading: false,
retryToken: 0,
fetch: async () => {
set({ loading: true, retryToken: get().retryToken + 1 })
try {
const res = await fetchData()
set({ data: res, loading: false, retryToken: get().retryToken + 1 })
} catch (err) {
// retry logic – each attempt increments the token
set({ loading: true, retryToken: get().retryToken + 1 })
// ... retry again or throw
}
}
))))