Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/sweet-forms-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions packages/form/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/*/
!/src/
152 changes: 152 additions & 0 deletions packages/form/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# @clerk/form

Reactive form primitives for Clerk SDKs — a [TanStack Form](https://tanstack.com/form)-style API whose reactive core is [nanostores](https://github.com/nanostores/nanostores) instead of `@tanstack/store`.

- **Typed field paths** via nanostores' `AllPaths`/`FromPath` (`'email'`, `'friends[0].name'`).
- **Validation** with plain functions _or_ any [Standard Schema](https://standardschema.dev) (zod 3.24+, valibot, arktype).
- **Async validation** tracked by nanostores `task()` (so tests can `await allTasks()`).
- **Minimal re-renders** in React via selector-based subscriptions.

> [!NOTE]
> Form data must be an object-literal `type`, not an `interface`. nanostores' path types require `Record<string, unknown>`, which only object-literal types satisfy (interfaces lack the implicit index signature).

## Core

```ts
import { createForm, createField } from '@clerk/form';

type Values = { email: string; friends: { name: string }[] };

const form = createForm<Values>({
defaultValues: { email: '', friends: [] },
validators: {
// form-level validator may target individual fields
onChange: ({ value }) => (value.email ? undefined : { fields: { email: 'Required' } }),
},
onSubmit: async ({ value }) => {
await api.save(value);
},
});

const email = createField(form, {
name: 'email',
validators: { onChange: ({ value }) => (value.includes('@') ? undefined : 'Invalid email') },
});

email.handleChange('a@b.c'); // setValue + runs change validation
email.handleBlur(); // marks touched + runs blur validation
email.state.value; // 'a@b.c'
email.state.meta.errors; // string[]

form.state.canSubmit; // derived: !isSubmitting && isValid
await form.handleSubmit();
```

### Validation

Each validator slot is a function or a Standard Schema. Async slots are debounced and cancel in-flight runs:

```ts
import { z } from 'zod';

createField(form, {
name: 'username',
validators: {
onChange: z.string().min(3), // Standard Schema
onChangeAsync: async ({ value, signal }) => {
const taken = await checkUsername(value, signal);
return taken ? 'Taken' : undefined;
},
onChangeAsyncDebounceMs: 300,
},
});
```

Cross-field validation via `listenTo`:

```ts
createField(form, {
name: 'confirm',
validators: {
onChange: ({ value, fieldApi }) => (value === fieldApi.form.getFieldValue('password') ? undefined : 'Mismatch'),
onChangeListenTo: ['password'], // re-validate when `password` changes
},
});
```

### Arrays

Array operations are standalone, tree-shakeable functions (nanostores-style — free functions over the store). Import only what you use; the base form does not bundle them.

```ts
import { pushFieldValue, insertFieldValue, removeFieldValue, swapFieldValues, moveFieldValues } from '@clerk/form';

pushFieldValue(form, 'friends', { name: '' });
insertFieldValue(form, 'friends', 0, { name: 'Sam' });
removeFieldValue(form, 'friends', 1);
swapFieldValues(form, 'friends', 0, 1);
moveFieldValues(form, 'friends', 0, 2);
```

## React

```tsx
import { useForm } from '@clerk/form/react';

function SignupForm() {
const form = useForm<Values>({ defaultValues: { email: '', friends: [] }, onSubmit });

return (
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit();
}}
>
<form.Field
name='email'
validators={{ onChange: z.string().email() }}
>
{field => (
<>
<input
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors[0] && <span>{field.state.meta.errors[0]}</span>}
</>
)}
</form.Field>

{/* re-renders only when `canSubmit` changes */}
<form.Subscribe selector={s => s.canSubmit}>
{canSubmit => (
<button
type='submit'
disabled={!canSubmit}
>
Save
</button>
)}
</form.Subscribe>
</form>
);
}
```

`useField`, `useStore` (a selector-aware `useSyncExternalStore` adapter), and `createFormHook` / `createFormHookContexts` (for app-level pre-wired field/form components) are also exported from `@clerk/form/react`.

## Field groups

Project a typed subset of the form as a self-contained section:

```ts
import { createFieldGroup } from '@clerk/form';

const address = createFieldGroup({ form, fields: 'address' }); // prefix form
address.setFieldValue('street', 'Main St'); // -> form 'address.street'

// or an explicit map
const group = createFieldGroup({ form, fields: { street: 'billing.street' } });
```
101 changes: 101 additions & 0 deletions packages/form/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
{
"name": "@clerk/form",
"version": "0.1.0",
"private": true,
"description": "Reactive form primitives for Clerk SDKs, built on nanostores.",
"repository": {
"type": "git",
"url": "git+https://github.com/clerk/javascript.git",
"directory": "packages/form"
},
"license": "MIT",
"author": "Clerk",
"sideEffects": false,
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./react": {
"import": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
},
"require": {
"types": "./dist/react/index.d.cts",
"default": "./dist/react/index.cjs"
}
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "tsdown",
"clean": "rimraf ./dist",
"dev": "tsdown --watch src",
"lint": "eslint src",
"size": "size-limit",
"size:why": "size-limit --why",
"test": "vitest run",
"test:ci": "vitest run --maxWorkers=70%",
"test:watch": "vitest"
},
"dependencies": {
"nanostores": "1.0.1"
},
"devDependencies": {
"@size-limit/preset-small-lib": "13.0.3",
"@testing-library/react": "^16.0.0",
"@types/react": "catalog:react",
"react": "catalog:react",
"react-dom": "catalog:react",
"rimraf": "6.0.1",
"size-limit": "13.0.3",
"tsdown": "catalog:repo",
"typescript": "catalog:repo"
},
"peerDependencies": {
"react": "catalog:peer-react"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"engines": {
"node": ">=20.9.0"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"size-limit": [
{
"name": "createForm",
"path": "dist/index.js",
"import": "{ createForm }",
"limit": "5 kB"
},
{
"name": "createForm + createField",
"path": "dist/index.js",
"import": "{ createForm, createField }",
"limit": "5 kB"
},
{
"name": "react: useForm",
"path": "dist/react/index.js",
"import": "{ useForm }",
"ignore": [
"react",
"react-dom"
],
"limit": "5.5 kB"
}
]
}
104 changes: 104 additions & 0 deletions packages/form/src/array/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';

import { createForm } from '../form';
import {
clearFieldValues,
insertFieldValue,
moveFieldValues,
pushFieldValue,
removeFieldValue,
replaceFieldValue,
swapFieldValues,
} from './index';

function listForm() {
return createForm({ defaultValues: { items: ['a', 'b', 'c'] } });
}

describe('array field operations', () => {
it('appends, inserts and removes by index', () => {
const form = listForm();
pushFieldValue(form, 'items', 'd');
expect(form.state.values.items).toEqual(['a', 'b', 'c', 'd']);
insertFieldValue(form, 'items', 0, 'z');
expect(form.state.values.items).toEqual(['z', 'a', 'b', 'c', 'd']);
removeFieldValue(form, 'items', 1);
expect(form.state.values.items).toEqual(['z', 'b', 'c', 'd']);
});

it('inserts at the end of the array', () => {
const form = listForm();
insertFieldValue(form, 'items', 3, 'd');
expect(form.state.values.items).toEqual(['a', 'b', 'c', 'd']);
});

it('swaps and moves items', () => {
const form = listForm();
swapFieldValues(form, 'items', 0, 2);
expect(form.state.values.items).toEqual(['c', 'b', 'a']);
moveFieldValues(form, 'items', 2, 0);
expect(form.state.values.items).toEqual(['a', 'c', 'b']);
});
});

describe('array index validation', () => {
it('rejects an out-of-range replace instead of creating a sparse array', () => {
const form = listForm();
expect(() => replaceFieldValue(form, 'items', 5, 'x')).toThrow(RangeError);
expect(form.state.values.items).toEqual(['a', 'b', 'c']);
});

it('rejects an out-of-range swap instead of writing undefined', () => {
const form = listForm();
expect(() => swapFieldValues(form, 'items', 0, 9)).toThrow(RangeError);
expect(form.state.values.items).toEqual(['a', 'b', 'c']);
});

it('rejects an out-of-range move instead of inserting undefined', () => {
const form = listForm();
expect(() => moveFieldValues(form, 'items', 9, 0)).toThrow(RangeError);
expect(() => moveFieldValues(form, 'items', 0, 9)).toThrow(RangeError);
expect(form.state.values.items).toEqual(['a', 'b', 'c']);
});

it('rejects an out-of-range insert or remove', () => {
const form = listForm();
expect(() => insertFieldValue(form, 'items', 4, 'x')).toThrow(RangeError);
expect(() => removeFieldValue(form, 'items', 3)).toThrow(RangeError);
expect(form.state.values.items).toEqual(['a', 'b', 'c']);
});

it('rejects a non-integer index', () => {
const form = listForm();
expect(() => replaceFieldValue(form, 'items', 1.5, 'x')).toThrow(RangeError);
expect(() => removeFieldValue(form, 'items', Number.NaN)).toThrow(RangeError);
expect(() => removeFieldValue(form, 'items', -1)).toThrow(RangeError);
});

it('rejects any index on an empty array', () => {
const form = createForm({ defaultValues: { items: [] as string[] } });
expect(() => removeFieldValue(form, 'items', 0)).toThrow(RangeError);
});
});

describe('array operations on non-array fields', () => {
it('rejects a field holding a scalar instead of overwriting it', () => {
const form = createForm({ defaultValues: { age: 30 } });
// @ts-expect-error 'age' is not an array field; the runtime guard covers JS callers.
expect(() => pushFieldValue(form, 'age', 1)).toThrow(TypeError);
expect(form.state.values.age).toBe(30);
});

it('rejects a field holding an object instead of overwriting it', () => {
const form = createForm({ defaultValues: { user: { name: 'bob' } } });
// @ts-expect-error 'user' is not an array field; the runtime guard covers JS callers.
expect(() => clearFieldValues(form, 'user')).toThrow(TypeError);
expect(form.state.values.user).toEqual({ name: 'bob' });
});

it('treats an absent field as an empty array', () => {
const form = createForm<{ items?: string[] }>({ defaultValues: {} });
pushFieldValue(form, 'items', 'a');
expect(form.state.values.items).toEqual(['a']);
});
});
Loading
Loading