How to Properly Sync Zustand and React Hook Form Data After Page Refresh? #12412
Replies: 3 comments
-
Can you provide a CodeSandbox link with a reproduction? Can you log this line during the initial render and see what values are being stored? const { name, email, address } = useFormStore((state) => state.form); Also, what does |
Beta Was this translation helpful? Give feedback.
-
looking for solution too. i have an identical problem with shadcn form, useForm with zod, separate component with input, and zustand. the only solution i found is to redirect user to page where he can return back with regular anchor, therefore to reset page |
Beta Was this translation helpful? Give feedback.
-
I think you need to write three main pieces
Basic implementation below (def test +tweak before you drop in without understanding!!!) // zustand store
import merge from 'lodash.merge';
import omit from 'lodash.omit';
import superjson from 'superjson';
import { persist } from 'zustand/middleware';
import { createWithEqualityFn as create } from 'zustand/traditional';
import '@/utils/superjson'; // repo-specific helpers for Decimal.js
type StorageCacheData<T> = {
storageId: string;
data: T;
lastUpdated: Date;
cacheLock?: string;
};
interface StorageCacheStore<T> {
addData: (storageId: string, data: T, cacheLock?: string) => string;
clear: () => void;
clearData: (storageId: string) => void;
dataById: { [storageId: string]: StorageCacheData<T> };
getData: (storageId: string) => StorageCacheData<T> | null;
updateData: (storageId: string, data: Partial<T>, cacheLock?: string) => void;
}
// Create a single store instance that works with any type
export const useStorageCacheStore = create(
persist<StorageCacheStore<unknown>>(
(set, get) => ({
addData: (storageId, data, cacheLock) => {
set((state) => ({
...state,
dataById: {
...state.dataById,
[storageId]: { cacheLock, data, lastUpdated: new Date(), storageId },
},
}));
return storageId;
},
clear: () => {
set({ dataById: {} });
},
clearData: (storageId) => {
set((state) => ({
...state,
dataById: omit(state.dataById, storageId),
}));
},
dataById: {},
getData: (storageId) => {
const dataById = get().dataById;
return dataById[storageId] || null;
},
updateData: (storageId, data, cacheLock) => {
set((state) => {
const lastUpdated = new Date();
const lastData = state.dataById[storageId] ?? { cacheLock, data };
const updatedData = { ...lastData, data: merge(lastData.data, data), lastUpdated };
return { ...state, dataById: { ...state.dataById, [storageId]: updatedData } };
});
},
}),
{
name: 'storage-cache',
storage: {
getItem: (name) => {
try {
const json = localStorage.getItem(name);
if (!json) return null;
return superjson.parse(json);
} catch {
return null;
}
},
removeItem: (name) => localStorage.removeItem(name),
setItem: (name, value) => {
localStorage.setItem(name, superjson.stringify(value));
},
},
version: 0, // notice: not super battle tested :)
},
),
); // util for diff and application
import { diff } from 'deep-diff';
interface ApplyCacheToFormOptions<T extends FieldValues> {
cachedFormValues: T;
defaultValues: T;
formId: string;
getValues: (path?: Path<T>) => T;
setValue: UseFormSetValue<T>;
}
export const applyCacheToForm = <T extends FieldValues>({
cachedFormValues,
defaultValues,
formId,
getValues,
setValue,
}: ApplyCacheToFormOptions<T>) => {
const shouldValidate = !formId.endsWith('new'); // up to you!
const touchOptions = { shouldDirty: true, shouldTouch: true, shouldValidate };
const cacheDiff = diff(defaultValues, cachedFormValues) ?? [];
for (const change of cacheDiff) {
if (!change.path) continue;
const path = change.path.join('.') as Path<T>;
switch (change.kind) {
case 'E': // Edit - property value changed
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue(path, change.rhs as any, touchOptions);
break;
case 'N': // New - property was added
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue(path, change.rhs as any, touchOptions);
break;
case 'D': // Deleted - property was removed
// For deleted properties, we set them to undefined/null to mark as dirty
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue(path, null as any, touchOptions);
break;
case 'A': // Array change
if (change.item) {
// Handle nested array changes
const arrayPath = [...change.path, change.index];
const arrayChangePath = arrayPath.join('.') as Path<T>;
switch (change.item.kind) {
case 'N': // New array element
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue(arrayChangePath, change.item.rhs as any, touchOptions);
break;
case 'D': {
// Deleted array element
const arrayToFilter = getValues(path) as unknown as unknown[];
if (arrayToFilter && Array.isArray(arrayToFilter)) {
const filteredArray = arrayToFilter.filter((_, index) => index !== change.index);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue(path, filteredArray as any, touchOptions);
}
break;
}
case 'E': // Edited array element
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue(arrayChangePath, change.item.rhs as any, touchOptions);
break;
}
}
break;
default:
console.warn('unhandled change kind', change);
}
}
}; // the hook that brings it altogether
interface UseStorageHydrationProps<T extends FieldValues> {
cacheLock?: string;
defaultValues: T;
formId: string;
formValues: T;
getValues: (path?: Path<T>) => T;
setValue: UseFormSetValue<T>;
}
export const useStorageHydration = <T extends FieldValues>({
cacheLock,
defaultValues,
formId, // a uniq identifier for that form (like user-new for creating a user or `model-${uuid}` for form that is doing an update)
formValues,
getValues,
setValue,
}: UseStorageHydrationProps<T>) => {
const [hasHydrated, setHasHydrated] = useState(false);
const [hasCacheConflict, setHasConflict] = useState(false);
const { addData, clearData, getData, updateData } = useStorageCacheStore();
// #region reset helper
const clearStorage = useCallback(() => {
setHasConflict(false);
clearData(formId);
}, [clearData, formId]);
// #endregion
// #region auto-save to store as values change
const cacheFormValues = useCallback(
(id: string, values: T, lock?: string) => updateData(id, values as Record<string, unknown>, lock),
[updateData],
);
const debouncedCacheFormValues = useDebouncedCallback(cacheFormValues, 1000, {
leading: false,
maxWait: 3000,
});
useEffect(() => {
if (!hasHydrated || hasCacheConflict) return; // skip writing until form has hydrated and no conflicts
if (formValues) debouncedCacheFormValues(formId, formValues, cacheLock);
}, [cacheLock, debouncedCacheFormValues, formId, formValues, hasCacheConflict, hasHydrated]);
// #endregion
// #region hydrate cache from store to form
useEffect(() => {
if (hasHydrated) return; // only run 1x
setHasHydrated(true);
const cacheStorage = getData(formId);
if (!cacheStorage) {
// If no cached data exists, start a cache
addData(formId, defaultValues, cacheLock);
return;
}
// Check for cache lock conflicts
if (cacheLock && cacheStorage.cacheLock && cacheStorage.cacheLock !== cacheLock) setHasConflict(true);
// Apply cache to form
const cachedFormValues = cacheStorage.data as T;
applyCacheToForm({ cachedFormValues, defaultValues, formId, getValues, setValue });
}, [addData, cacheLock, defaultValues, formId, getData, getValues, hasHydrated, setValue]);
// #endregion
return { clearStorage, hasCacheConflict, hasHydrated };
}; // example usage (likely in a form component)
import { zodResolver } from '@hookform/resolvers/zod';
...
const formMethods = useForm<MyFormType>({
defaultValues,
resolver: zodResolver(zMyFormType),
});
const { getValues: getFormValues, reset, setValue: setFormValue } = formMethods;
// #endregion
// #region auto-save via storage cache
const formValues = useWatch({ control: formMethods.control }) as SalesOrderData;
const { clearStorage, hasCacheConflict, hasHydrated } = useStorageHydration<SalesOrderData>({
cacheLock,
defaultValues,
formId,
formValues,
getValues: getFormValues,
setValue: setFormValue,
});
// #endregion
// #region reset form
const resetForm = useCallback(() => {
reset(defaultValues);
clearStorage();
}, [clearStorage, defaultValues, reset]);
// #endregion
if (!hasHydrated) return <p>{"Loading..."}</p> // or a skeleton
if (hasCacheConflict) return <p>A warning that prompts users to 'clearStorage'</p>
// otherwise, render your form! |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Hello guys, I'm new to working with React Hook Form and Zod. I'm building a form with Shadcn, React Hook Form, and Zod, and I'm implementing Zustand to persist data across page refreshes.
The issue I'm running into is that after a page refresh or when a user navigates back to the form, the input data persists visually as expected, but React Hook Form throws validation errors on submit, as if the inputs were empty. From what I understand, It seems like React Hook Form doesn’t really sync with Zustand's persisted data, which causes validation to fail.
Zustand Form Store
Input Field Component
I've found a workaround that involves using React Hook Form’s reset function in a useEffect to update the states to match Zustand's values when the component mounts. While this works, it may not be the best solution/optimal way because I have many forms with 10+ fields. So adding useEffect in every form would look very messy and not ideal.
If anyone else encountered this issue or know a cleaner/better solution, please please share your knowledge. I'd really appreciate any guidance. Thanks in advance!
Beta Was this translation helpful? Give feedback.
All reactions