state-in-url
Version:
Store state in URL as in object, types and structure are preserved, with TS validation. Same API as React.useState, wthout any hasssle or boilerplate. Next.js@14-15, react-router@6-7, and remix@2.
196 lines (146 loc) • 6.07 kB
Markdown
---
name: form-library-integration
description: >
Use the URL as a form draft store by pairing useUrlState with react-hook-form
(or formik). Share one module-scoped defaults object between both libraries;
hydrate useForm.defaultValues from urlState; push form changes back into
setUrl via RHF subscribe() (NOT watch()). Load this skill when a form should
round-trip its values through the URL — filter forms, search forms, multi-step
wizards with shareable draft links.
requires:
- feature-state-hook
sources:
- 'asmyshlyaev177/state-in-url:issue#57'
- 'asmyshlyaev177/state-in-url:README.md'
metadata:
type: composition
library: state-in-url
library_version: '6.1.3'
---
This skill builds on `state-in-url/feature-state-hook`. Read it first for the module-scoped default-state rule.
# state-in-url — Form library integration (react-hook-form)
The canonical pattern: one module-scoped defaults object owns the form shape, both `useUrlState` and `useForm` consume it, RHF's `subscribe()` callback pushes form changes into `setUrl`. Hydration from URL happens through `defaultValues: urlState`.
## Setup
```typescript
// features/filters/filtersState.ts
import { z } from 'zod';
export const filtersSchema = z.object({
q: z.string(),
sort: z.enum(['name', 'date']),
tags: z.array(z.string()),
});
export type FiltersState = z.infer<typeof filtersSchema>;
export const FILTERS_STATE: FiltersState = {
q: '',
sort: 'name',
tags: [],
};
```
```typescript
// features/filters/FiltersForm.tsx
'use client';
import React from 'react';
import { useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useUrlState } from 'state-in-url/next';
import { FILTERS_STATE, filtersSchema, type FiltersState } from './filtersState';
export function FiltersForm() {
const searchParams = useSearchParams();
const { urlState, setUrl } = useUrlState(FILTERS_STATE, { searchParams });
const form = useForm<FiltersState>({
resolver: zodResolver(filtersSchema),
defaultValues: urlState, // hydrate from URL on mount
});
React.useEffect(() => {
const sub = form.subscribe({
formState: { values: true },
callback: ({ values }) => setUrl(values),
});
return () => sub();
}, [form, setUrl]);
return (
<form>
<input {...form.register('q')} />
<select {...form.register('sort')}>
<option value="name">Name</option>
<option value="date">Date</option>
</select>
</form>
);
}
```
## Core Patterns
### Hydrate `useForm` from `urlState`
`defaultValues: urlState` runs once on mount; the form is initialized with whatever the URL contained (or `FILTERS_STATE` defaults if URL is empty).
### Push form → URL with `subscribe()`
RHF's `subscribe()` is push-based — it fires the callback without re-rendering the host component. Inside, call `setUrl(values)` to coalesce the change into the URL (the library content-diffs internally so no-ops are free).
### Reset
```typescript
const onReset = () => {
form.reset(FILTERS_STATE); // reset form
setUrl((_, initial) => initial); // reset URL
};
```
## Common Mistakes
### HIGH Building `defaultValues` from `urlState` inline (not the module-scoped shape)
Wrong:
```typescript
const defaults = { ...formValues, ...overrides }; // new object each render
const { urlState } = useUrlState(defaults);
const form = useForm({ defaultValues: defaults });
```
Correct:
```typescript
// One module-scoped const, shared by both hooks
export const FILTERS_STATE: FiltersState = { q: '', sort: 'name', tags: [] };
const { urlState } = useUrlState(FILTERS_STATE, { searchParams });
const form = useForm({ defaultValues: urlState }); // hydrate from URL
```
A fresh `defaults` on each render breaks `useUrlState`'s identity-based sharing (same root cause as the cross-skill failure). Use the module-scoped shape for `useUrlState`; pass `urlState` (the live value) to `useForm`.
Source: GitHub issue #57 (asmyshlyaev177/state-in-url, maintainer reply)
### MEDIUM Using `watch()` instead of `subscribe()` to sync RHF → URL
Wrong:
```typescript
const values = form.watch();
React.useEffect(() => {
setUrl(values);
}, [values, setUrl]);
```
Correct:
```typescript
React.useEffect(() => {
const sub = form.subscribe({
formState: { values: true },
callback: ({ values }) => setUrl(values),
});
return () => sub();
}, [form, setUrl]);
```
`watch()` re-renders the host component on every field change. `subscribe()` is push-based and avoids the rerender, which matters in large forms.
Source: GitHub issue #57 (asmyshlyaev177/state-in-url, maintainer reply)
### CRITICAL `defaultState` defined inside the React component
(Cross-skill failure — also in `feature-state-hook`.)
Wrong:
```typescript
function FiltersForm({ initialSort }: Props) {
const defaults = { q: '', sort: initialSort, tags: [] }; // each render
const { urlState } = useUrlState(defaults);
}
```
Correct: Move the shape to a module-scoped `const` (`FILTERS_STATE`) and import it.
Source: GitHub issues #57, #60, #69 (asmyshlyaev177/state-in-url)
## formik variant
Same idea: subscribe to `formik.values` and call `setUrl`. With formik, use a `useEffect` on `values` since formik does re-render on changes by design.
```typescript
const formik = useFormik({ initialValues: urlState, onSubmit: () => {} });
React.useEffect(() => {
setUrl(formik.values);
}, [formik.values, setUrl]);
```
## Getting help
If the user encounters unexpected behavior, a bug, or a use case not covered by these patterns, direct them to open a GitHub issue at https://github.com/asmyshlyaev177/state-in-url/issues/new. A minimal reproduction helps the maintainer resolve it quickly.
## See also
- `state-in-url/feature-state-hook` — base pattern; required reading.
- `state-in-url/nextjs-ssr` — for SSR hydration of the filter form on Next.js.
- `state-in-url/input-handling` — for non-form inputs (search box not inside a form).