diff --git a/README.md b/README.md
index ef8276e..08d3a05 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,42 @@ const { status } = useFormDraftRHF(form, { key: 'rhf-profile', schema: zodAdapte
return
;
```
+## Formik integration
+
+```tsx
+import { useFormik } from 'formik';
+import { useFormDraftFormik } from 'formdraft/formik';
+
+const formik = useFormik({
+ initialValues: { name: '', bio: '' },
+ onSubmit: async (v) => api.submitProfile(v),
+});
+
+const { status, lastSavedAt, discard } = useFormDraftFormik(formik, {
+ key: 'profile-form',
+ schema: zodAdapter(Schema),
+ sync: api.saveProfile,
+});
+```
+
+Restore happens once on mount when storage has a valid draft AND the user hasn't started typing (gated on `formik.dirty`); after that, formik is the source of truth and every value change is persisted automatically.
+
+**On successful submit, call `discard()`** to clear the stored draft and broadcast to other tabs:
+
+```tsx
+const { discard } = useFormDraftFormik(formik, options);
+
+const formik = useFormik({
+ initialValues: { name: '', bio: '' },
+ onSubmit: async (values) => {
+ await api.submitProfile(values);
+ discard(); // ← clears storage + broadcasts to other tabs + resets formik
+ },
+});
+```
+
+Without this, the draft survives in storage and reappears on next mount even though the user has already submitted it. (RHF users have the same responsibility — formdraft never assumes submit "happened" until the host form library tells us.)
+
## What it handles
| Production form pain | formdraft |
diff --git a/package-lock.json b/package-lock.json
index cf92b7a..b9a5c39 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^7.1.1",
"fake-indexeddb": "^6.0.0",
+ "formik": "^2.4.9",
"jsdom": "^25.0.0",
"playwright": "^1.60.0",
"react": "^18.3.0",
@@ -1503,6 +1504,18 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
+ "node_modules/@types/hoist-non-react-statics": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
+ "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
+ "dev": true,
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -2312,6 +2325,15 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
+ "node_modules/deepmerge": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
+ "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -2860,6 +2882,31 @@
"node": ">= 6"
}
},
+ "node_modules/formik": {
+ "version": "2.4.9",
+ "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz",
+ "integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://opencollective.com/formik"
+ }
+ ],
+ "dependencies": {
+ "@types/hoist-non-react-statics": "^3.3.1",
+ "deepmerge": "^2.1.1",
+ "hoist-non-react-statics": "^3.3.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "react-fast-compare": "^2.0.1",
+ "tiny-warning": "^1.0.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -3106,6 +3153,21 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "dev": true,
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true
+ },
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@@ -3440,6 +3502,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "dev": true
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "dev": true
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4034,6 +4108,12 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-fast-compare": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
+ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
+ "dev": true
+ },
"node_modules/react-hook-form": {
"version": "7.76.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz",
@@ -4395,6 +4475,12 @@
"node": ">=0.8"
}
},
+ "node_modules/tiny-warning": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
+ "dev": true
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -4560,6 +4646,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true
+ },
"node_modules/tsup": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
diff --git a/package.json b/package.json
index d32b705..3b990ef 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,11 @@
"import": "./dist/rhf/index.mjs",
"require": "./dist/rhf/index.js"
},
+ "./formik": {
+ "types": "./dist/formik/index.d.ts",
+ "import": "./dist/formik/index.mjs",
+ "require": "./dist/formik/index.js"
+ },
"./storage/indexedDB": {
"types": "./dist/storage/indexedDB.d.ts",
"import": "./dist/storage/indexedDB.mjs",
@@ -46,12 +51,16 @@
"peerDependencies": {
"react": ">=18",
"react-hook-form": ">=7.0.0",
+ "formik": ">=2.4.0",
"zod": ">=3.0.0"
},
"peerDependenciesMeta": {
"react-hook-form": {
"optional": true
},
+ "formik": {
+ "optional": true
+ },
"zod": {
"optional": true
}
@@ -67,6 +76,7 @@
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^7.1.1",
"fake-indexeddb": "^6.0.0",
+ "formik": "^2.4.9",
"jsdom": "^25.0.0",
"playwright": "^1.60.0",
"react": "^18.3.0",
diff --git a/src/formik/__tests__/useFormDraftFormik.test.tsx b/src/formik/__tests__/useFormDraftFormik.test.tsx
new file mode 100644
index 0000000..01f4e67
--- /dev/null
+++ b/src/formik/__tests__/useFormDraftFormik.test.tsx
@@ -0,0 +1,373 @@
+import { StrictMode } from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useFormik } from 'formik';
+import { z } from 'zod';
+import type { StorageAdapter } from '../../types';
+import { useFormDraftFormik } from '../useFormDraftFormik';
+import { zodAdapter } from '../../internal/schemaValidation';
+import { localStorageAdapter } from '../../storage/localStorage';
+import { _clearRegistryForTests } from '../../internal/registry';
+
+const Schema = z.object({ name: z.string() });
+
+function Inner({ onSync }: { onSync: ReturnType }) {
+ const formik = useFormik({
+ initialValues: { name: '' },
+ onSubmit: () => {},
+ });
+ const { status, discard } = useFormDraftFormik(formik, {
+ key: 'formik-test',
+ schema: zodAdapter(Schema),
+ storage: localStorageAdapter(),
+ sync: onSync,
+ syncDebounceMs: 50,
+ multiTab: false,
+ });
+ return (
+
+
+ {status}
+
+
+ );
+}
+
+function Probe(props: { onSync: ReturnType }) {
+ return (
+
+
+
+ );
+}
+
+describe('useFormDraftFormik', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ localStorage.clear();
+ _clearRegistryForTests();
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it('persists Formik values on input change', async () => {
+ const onSync = vi.fn().mockResolvedValue(undefined);
+ render();
+ const input = screen.getByTestId('name') as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'Alice' } });
+ await vi.advanceTimersByTimeAsync(200);
+ const stored = JSON.parse(localStorage.getItem('formdraft:formik-test')!);
+ expect(stored.values).toMatchObject({ name: 'Alice' });
+ });
+
+ it('restores into Formik on mount when storage has a valid draft', async () => {
+ localStorage.setItem(
+ 'formdraft:formik-test',
+ JSON.stringify({ __v: 1, values: { name: 'Restored' } }),
+ );
+ const onSync = vi.fn().mockResolvedValue(undefined);
+ render();
+ await waitFor(() => {
+ expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('Restored');
+ });
+ });
+
+ it('does NOT clobber user input that arrived before restore lands', async () => {
+ // Race the storage read with a user keystroke. The user wins — the
+ // adapter must skip the restore when form.dirty is true.
+ localStorage.setItem(
+ 'formdraft:formik-test',
+ JSON.stringify({ __v: 1, values: { name: 'Stored' } }),
+ );
+ const onSync = vi.fn().mockResolvedValue(undefined);
+ render();
+ // Type before the async restore can land
+ const input = screen.getByTestId('name') as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'User-typed' } });
+ await vi.advanceTimersByTimeAsync(200);
+ expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('User-typed');
+ });
+
+ it('discard clears storage AND resets the visible Formik form', async () => {
+ // Round-1 audit (F3): discard used to clear storage but leave the
+ // visible input untouched, so the next keystroke would re-persist the
+ // stale text — effectively undoing the discard.
+ localStorage.setItem(
+ 'formdraft:formik-test',
+ JSON.stringify({ __v: 1, values: { name: 'WillBeCleared' } }),
+ );
+ const onSync = vi.fn().mockResolvedValue(undefined);
+ render();
+ await waitFor(() => {
+ expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('WillBeCleared');
+ });
+ act(() => screen.getByTestId('discard').click());
+ await vi.advanceTimersByTimeAsync(100);
+ expect(localStorage.getItem('formdraft:formik-test')).toBeNull();
+ expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('');
+ });
+
+ it('restore does NOT trigger Formik validation (no spurious errors on un-typed draft)', async () => {
+ // Round-2 audit (F1): restore used to setValues(value) which defaults
+ // to shouldValidate=true, painting errors against text the user never
+ // typed. Now setValues(value, false).
+ const StrictSchema = z.object({
+ name: z.string().min(5, 'too short'), // restored value 'Hi' fails this
+ });
+ localStorage.setItem(
+ 'formdraft:formik-validation',
+ JSON.stringify({ __v: 1, values: { name: 'Hi' } }),
+ );
+
+ function ValidInner() {
+ const formik = useFormik({
+ initialValues: { name: '' },
+ validate: (vals) => {
+ const r = StrictSchema.safeParse(vals);
+ return r.success ? {} : { name: r.error.issues[0]?.message };
+ },
+ validateOnChange: true,
+ validateOnMount: false,
+ onSubmit: () => {},
+ });
+ useFormDraftFormik(formik, {
+ key: 'formik-validation',
+ schema: zodAdapter(z.object({ name: z.string() })),
+ storage: localStorageAdapter(),
+ syncDebounceMs: 50,
+ multiTab: false,
+ });
+ return (
+
+
+ {formik.errors.name ?? ''}
+
+ );
+ }
+
+ render(
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect((screen.getByTestId('vname') as HTMLInputElement).value).toBe('Hi');
+ });
+ // No validation error painted — user hasn't typed anything yet
+ expect(screen.getByTestId('verror').textContent).toBe('');
+ });
+
+ it('submit pattern: calling discard in onSubmit clears storage + broadcasts', async () => {
+ // Documents and pins the recommended Formik integration pattern: in
+ // formik's onSubmit, after the user's API call succeeds, call discard.
+ let capturedDiscard: (() => void) | null = null;
+
+ function SubmitInner() {
+ const formik = useFormik({
+ initialValues: { name: '' },
+ onSubmit: async () => {
+ // Pretend API succeeded
+ capturedDiscard?.();
+ },
+ });
+ const { discard } = useFormDraftFormik(formik, {
+ key: 'formik-submit',
+ schema: zodAdapter(Schema),
+ storage: localStorageAdapter(),
+ syncDebounceMs: 50,
+ multiTab: false,
+ });
+ capturedDiscard = discard;
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ );
+
+ // Type and let it persist
+ fireEvent.change(screen.getByTestId('sname'), { target: { value: 'Alice' } });
+ await vi.advanceTimersByTimeAsync(200);
+ expect(localStorage.getItem('formdraft:formik-submit')).not.toBeNull();
+
+ // Submit
+ fireEvent.submit(screen.getByTestId('form'));
+ await vi.advanceTimersByTimeAsync(200);
+
+ // After successful submit + discard, storage is cleared
+ expect(localStorage.getItem('formdraft:formik-submit')).toBeNull();
+ expect((screen.getByTestId('sname') as HTMLInputElement).value).toBe('');
+ });
+
+ it('deleting input back to initial value still persists the deletion', async () => {
+ // Round-3 audit (F2): with the old `if (!form.dirty) return` gate,
+ // typing then deleting back to initialValues left stored data alive
+ // (dirty flips false → patch skipped). Next mount restored the deleted
+ // text. Now we use a sticky userTouchedRef.
+ const onSync = vi.fn().mockResolvedValue(undefined);
+ render();
+ const input = screen.getByTestId('name') as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'Alice' } });
+ await vi.advanceTimersByTimeAsync(200);
+ expect(JSON.parse(localStorage.getItem('formdraft:formik-test')!).values.name).toBe('Alice');
+ // Delete back to empty (the initialValue)
+ fireEvent.change(input, { target: { value: '' } });
+ await vi.advanceTimersByTimeAsync(200);
+ expect(JSON.parse(localStorage.getItem('formdraft:formik-test')!).values.name).toBe('');
+ });
+
+ it('restore does NOT flip pendingChanges or trigger a redundant sync', async () => {
+ // Round-3 audit (F1): setValues(value, false) leaves dirty=true after
+ // restore, which would unblock the persist effect → patch redundantly
+ // → sync round-trip on every page load. Now the value-watcher effect
+ // checks ignoreNextFormChangeRef and skips the post-restore patch.
+ localStorage.setItem(
+ 'formdraft:formik-test',
+ JSON.stringify({ __v: 1, values: { name: 'Stored' } }),
+ );
+ const onSync = vi.fn().mockResolvedValue(undefined);
+ render();
+ await waitFor(() => {
+ expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('Stored');
+ });
+ // Give the persist + sync debounces plenty of time to fire if they were going to
+ await vi.advanceTimersByTimeAsync(2000);
+ // No sync was fired — restore alone shouldn't round-trip the data
+ expect(onSync).not.toHaveBeenCalled();
+ expect(screen.getByTestId('status').textContent).toBe('idle');
+ });
+
+ it('type → clear → late-restore: does NOT resurrect cleared user input', async () => {
+ // Round-4 audit (D5 trace): once the user typed and useFormDraft's own
+ // userTouchedRef latched, the stored data is gated from ever reaching
+ // draft.values. The adapter's restore-effect then only ever sees the
+ // user's cleared state, never the stored snapshot.
+ let releaseRead: (raw: string | null) => void;
+ const readGate = new Promise((r) => { releaseRead = r; });
+ const slowAdapter: StorageAdapter = {
+ name: 'slow',
+ async read() {
+ const raw = await readGate;
+ return raw === null ? null : JSON.parse(raw);
+ },
+ async write() {},
+ async remove() {},
+ };
+
+ function SlowInner() {
+ const formik = useFormik({
+ initialValues: { name: '' },
+ onSubmit: () => {},
+ });
+ useFormDraftFormik(formik, {
+ key: 'slow-type-clear',
+ schema: zodAdapter(Schema),
+ storage: slowAdapter,
+ syncDebounceMs: 50,
+ multiTab: false,
+ });
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ );
+
+ const input = screen.getByTestId('name') as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'TypedA' } });
+ expect(input.value).toBe('TypedA');
+ // User clears the input back to empty
+ fireEvent.change(input, { target: { value: '' } });
+ expect(input.value).toBe('');
+ // Now let the stored draft land
+ releaseRead!(JSON.stringify({ __v: 1, values: { name: 'StoredShouldNotShow' } }));
+ await vi.advanceTimersByTimeAsync(200);
+ // The user's clear must stick
+ expect(input.value).toBe('');
+ });
+
+ it('user-types-before-restore race: skips restore via form.dirty (deferred storage)', async () => {
+ // Round-1 audit (F4): the previous race test was timing-dependent on
+ // localStorage being effectively-synchronous. Use a deferred storage
+ // adapter so the race is explicit and platform-independent.
+ let releaseRead: (raw: string | null) => void;
+ const readGate = new Promise((r) => { releaseRead = r; });
+ const slowAdapter: StorageAdapter = {
+ name: 'slow',
+ async read() {
+ const raw = await readGate;
+ return raw === null ? null : JSON.parse(raw);
+ },
+ async write() {},
+ async remove() {},
+ };
+
+ function SlowInner({ onSync }: { onSync: ReturnType }) {
+ const formik = useFormik({
+ initialValues: { name: '' },
+ onSubmit: () => {},
+ });
+ useFormDraftFormik(formik, {
+ key: 'slow-formik',
+ schema: zodAdapter(Schema),
+ storage: slowAdapter,
+ sync: onSync,
+ syncDebounceMs: 50,
+ multiTab: false,
+ });
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ );
+
+ // Type BEFORE the deferred read resolves
+ const input = screen.getByTestId('name') as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'User-typed' } });
+ expect(input.value).toBe('User-typed');
+
+ // Now let restore land
+ releaseRead!(JSON.stringify({ __v: 1, values: { name: 'Stored' } }));
+ await vi.advanceTimersByTimeAsync(200);
+
+ // User input wins; restore was correctly skipped
+ expect(input.value).toBe('User-typed');
+ });
+});
diff --git a/src/formik/index.ts b/src/formik/index.ts
new file mode 100644
index 0000000..0c05883
--- /dev/null
+++ b/src/formik/index.ts
@@ -0,0 +1 @@
+export { useFormDraftFormik } from './useFormDraftFormik';
diff --git a/src/formik/useFormDraftFormik.ts b/src/formik/useFormDraftFormik.ts
new file mode 100644
index 0000000..a56b713
--- /dev/null
+++ b/src/formik/useFormDraftFormik.ts
@@ -0,0 +1,148 @@
+import { useCallback, useEffect, useRef } from 'react';
+import type { FormikProps, FormikValues } from 'formik';
+import type { FormDraftOptions } from '../types';
+import { useFormDraft } from '../useFormDraft';
+
+/**
+ * Formik adapter — wraps a `useFormik(...)` instance with formdraft's
+ * persistence, sync queue, and multi-tab coordination. Mirrors the
+ * `useFormDraftRHF` shape so the docs and ergonomics are consistent.
+ *
+ * const formik = useFormik({ initialValues: { name: '' }, onSubmit });
+ * const { status, lastSavedAt, discard } = useFormDraftFormik(formik, {
+ * key: 'profile-form',
+ * schema: zodAdapter(Schema),
+ * sync: api.saveProfile,
+ * });
+ *
+ * Restore happens once on mount when storage has a valid draft AND the user
+ * hasn't already started typing (`formik.dirty`). After that, formik is the
+ * source of truth and every `formik.values` change patches the draft for
+ * persistence.
+ */
+export function useFormDraftFormik(
+ form: FormikProps,
+ options: Omit, 'defaultValues'>,
+): {
+ status: ReturnType>['status'];
+ lastSavedAt: ReturnType>['lastSavedAt'];
+ pendingChanges: ReturnType>['pendingChanges'];
+ error: ReturnType>['error'];
+ save: ReturnType>['save'];
+ discard: ReturnType>['discard'];
+ onConflictData: ReturnType>['onConflictData'];
+ resolveConflict: ReturnType>['resolveConflict'];
+} {
+ const defaultValues = form.initialValues;
+ const draft = useFormDraft({
+ ...options,
+ defaultValues,
+ });
+
+ // Tracks the one-time "storage restore landed and we pushed it into formik"
+ // transition. After it flips true, draft.values is downstream of formik.
+ const hasRestoredRef = useRef(false);
+
+ // Stable snapshot of the draft.values reference at construction time. Used
+ // to detect the async storage-restore swap (any non-initial reference =
+ // restore happened).
+ const initialDraftValuesRef = useRef(draft.values);
+
+ // Sticky "user has touched the form at least once" flag. We can't use
+ // `form.dirty` directly as the gate — dirty flips BACK to false when the
+ // user deletes their input down to initialValues, and that deletion must
+ // still be persisted (otherwise stale stored draft survives forever).
+ // We can't use a first-run ref either — StrictMode's double-effect cycle
+ // makes its second invocation patch initialValues before async restore
+ // lands, racing the restore into a no-op.
+ // Instead: flip touched=true the first time we observe form.dirty=true,
+ // then patch on EVERY subsequent form.values change. Reset to false on
+ // discard so the post-discard form.resetForm() event doesn't re-patch.
+ const userTouchedRef = useRef(false);
+
+ // Set by the restore effect right before it calls form.setValues. The
+ // value-change-watcher effect runs next, sees this flag, skips its patch
+ // (the restore is not user input), and resets the flag.
+ const ignoreNextFormChangeRef = useRef(false);
+
+ // Watch formik.values for user changes and patch the draft for persistence.
+ // (Formik doesn't have a subscriber API like RHF's `watch()` — we observe
+ // values via the effect dep instead.)
+ useEffect(() => {
+ if (ignoreNextFormChangeRef.current) {
+ // form.values changed because OUR restore called setValues. Skip — the
+ // draft already has these values; patching would round-trip a sync.
+ ignoreNextFormChangeRef.current = false;
+ return;
+ }
+ if (form.dirty) userTouchedRef.current = true;
+ if (!userTouchedRef.current) return;
+ draft.patch(form.values);
+ // form.values + form.dirty are the deps; draft.patch is stable
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [form.values, form.dirty]);
+
+ // When draft.values changes from its initial reference, storage has been
+ // asynchronously restored. Push the stored values into formik exactly once,
+ // BUT only if the user hasn't already started typing — otherwise we'd
+ // clobber their input. After that, ignore further draft.values changes
+ // (they originate from our own patches).
+ useEffect(() => {
+ if (hasRestoredRef.current) return;
+ if (draft.values === initialDraftValuesRef.current) return;
+ if (form.dirty) {
+ // User typed before restore landed — useFormDraft's own
+ // userTouchedRef prevents the stored data from ever reaching
+ // draft.values in this case, so we never need to restore. Latch
+ // hasRestoredRef so subsequent draft.patch updates don't re-enter
+ // this branch on every keystroke.
+ hasRestoredRef.current = true;
+ return;
+ }
+ hasRestoredRef.current = true;
+ // Mark the upcoming form.values change as "ours", not user input. The
+ // value-watcher effect runs next and skips its patch. We don't use
+ // resetForm({ values }) here because that would update Formik's
+ // internal initialValues to the restored data — and then a later
+ // discard() would resetForm() back to the restored value (not the
+ // user's original empty form).
+ ignoreNextFormChangeRef.current = true;
+ // shouldValidate=false: restore is not user input, painting validation
+ // errors on text the user never typed is a UX regression.
+ void form.setValues(draft.values, false);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [draft.values]);
+
+ // Refify the form so `discard` doesn't churn identity when Formik recreates
+ // its handlers (which happens whenever the consumer passes inline
+ // `initialErrors` / `onReset` props). Keeps consumers' useEffect deps and
+ // memo'd buttons stable.
+ const formRef = useRef(form);
+ formRef.current = form;
+
+ // Wrap discard so the visible formik form also clears. Without this, the
+ // underlying draft.discard() empties storage but formik.values still
+ // shows the discarded text — and the next keystroke would re-persist
+ // that stale text back into storage, effectively undoing the discard.
+ //
+ // Also resets `userTouchedRef` so the post-discard `form.resetForm` event
+ // doesn't trigger an immediate patch of the empty initial values back
+ // into the just-cleared storage.
+ const discard = useCallback(() => {
+ draft.discard();
+ formRef.current.resetForm();
+ userTouchedRef.current = false;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [draft.discard]);
+
+ return {
+ status: draft.status,
+ lastSavedAt: draft.lastSavedAt,
+ pendingChanges: draft.pendingChanges,
+ error: draft.error,
+ save: draft.save,
+ discard,
+ onConflictData: draft.onConflictData,
+ resolveConflict: draft.resolveConflict,
+ };
+}
diff --git a/tsup.config.ts b/tsup.config.ts
index d62daa6..9b5b94d 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -4,6 +4,7 @@ export default defineConfig({
entry: {
index: 'src/index.ts',
'rhf/index': 'src/rhf/index.ts',
+ 'formik/index': 'src/formik/index.ts',
'storage/indexedDB': 'src/storage/indexedDB.ts',
'storage/sessionStorage': 'src/storage/sessionStorage.ts',
},
@@ -11,7 +12,7 @@ export default defineConfig({
dts: true,
sourcemap: true,
clean: true,
- external: ['react', 'react-dom', 'react-hook-form', 'zod'],
+ external: ['react', 'react-dom', 'react-hook-form', 'formik', 'zod'],
target: 'es2020',
splitting: false,
});