How to write forms with Jazz
This guide shows you a simple and powerful way to implement forms for creating and updating CoValues.
Updating a CoValue
To update a CoValue, we simply assign the new value directly as changes happen. These changes are synced to the server.
<input type="text" value={order.name} onChange={(e) => order.$jazz.set("name", e.target.value)} />
It's that simple!
Creating a CoValue
When creating a CoValue, we can use a partial version that allows us to build up the data before submitting.
Using a Partial CoValue
Let's say we have a CoValue called BubbleTeaOrder
. We can create a partial version,
PartialBubbleTeaOrder
, which has some fields made optional so we can build up the data incrementally.
// schema.ts export const
BubbleTeaOrder =
const BubbleTeaOrder: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>
import co
co.map({
map<{ name: z.z.ZodString; }>(shape: { name: z.z.ZodString; }): co.Map<{ name: z.z.ZodString; }, unknown, Account | Group> export map
name: z.z.ZodString
name:import z
z.string(), }); export type
function string(params?: string | z.z.core.$ZodStringParams): z.z.ZodString (+1 overload) export string
BubbleTeaOrder =
type BubbleTeaOrder = { readonly name: string; } & CoMap
import co
co.loaded<typeof
type loaded<T extends CoValueClassOrSchema, R extends ResolveQuery<T> = true> = R extends boolean | undefined ? NonNullable<InstanceOfSchemaCoValuesNullable<T>> : [NonNullable<InstanceOfSchemaCoValuesNullable<T>>] extends [...] ? Exclude<...> extends CoValue ? R extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean ... export loaded
BubbleTeaOrder>; export const
const BubbleTeaOrder: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>
PartialBubbleTeaOrder =
const PartialBubbleTeaOrder: co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>
BubbleTeaOrder.
const BubbleTeaOrder: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>
CoMapSchema<{ name: ZodString; }, unknown, Account | Group>.partial<"name">(keys?: { name: true; } | undefined): co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>
Creates a new CoMap schema by making all fields optional.partial(); export typePartialBubbleTeaOrder =
type PartialBubbleTeaOrder = { readonly name: string | undefined; } & CoMap
import co
co.loaded<typeof
type loaded<T extends CoValueClassOrSchema, R extends ResolveQuery<T> = true> = R extends boolean | undefined ? NonNullable<InstanceOfSchemaCoValuesNullable<T>> : [NonNullable<InstanceOfSchemaCoValuesNullable<T>>] extends [...] ? Exclude<...> extends CoValue ? R extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean | undefined ? CoValue & Exclude<...> : [...] extends [...] ? Exclude<...> extends CoValue ? ItemDepth extends { ...; } ? readonly ((CoValue & ... 1 more ... & (ItemDepth extends boolean ... export loaded
PartialBubbleTeaOrder>;
const PartialBubbleTeaOrder: co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>
Writing the components in React
Let's write the form component that will be used for both create and update.
// OrderForm.tsx export function
OrderForm({
function OrderForm({ order, onSave }: { order: BubbleTeaOrder | PartialBubbleTeaOrder; onSave?: (e: React.FormEvent<HTMLFormElement>) => void; }): React.JSX.Element
order,
order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)
onSave: ((e: React.FormEvent<HTMLFormElement>) => void) | undefined
onSave }: {order:
order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)
BubbleTeaOrder |
type BubbleTeaOrder = { readonly name: string; } & CoMap
PartialBubbleTeaOrder;
type PartialBubbleTeaOrder = { readonly name: string | undefined; } & CoMap
onSave?: ((e: React.FormEvent<HTMLFormElement>) => void) | undefined
onSave?: (e: React.FormEvent<HTMLFormElement>
e: React.interface React.FormEvent<T = Element>
FormEvent<HTMLFormElement>) => void; }) { return ( <React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>
formReact.DOMAttributes<HTMLFormElement>.onSubmit?: React.FormEventHandler<HTMLFormElement> | undefined
onSubmit={onSave: ((e: React.FormEvent<HTMLFormElement>) => void) | undefined
onSave || (e: React.FormEvent<HTMLFormElement>
e =>e: React.FormEvent<HTMLFormElement>
e.React.BaseSyntheticEvent<Event, EventTarget & HTMLFormElement, EventTarget>.preventDefault(): void
preventDefault())}> <React.JSX.IntrinsicElements.label: React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>
label> Name <React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
inputReact.InputHTMLAttributes<HTMLInputElement>.type?: React.HTMLInputTypeAttribute | undefined
type="text"React.InputHTMLAttributes<HTMLInputElement>.value?: string | number | readonly string[] | undefined
value={order.
order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)
name: string | undefined
name || ""}React.InputHTMLAttributes<HTMLInputElement>.onChange?: React.ChangeEventHandler<HTMLInputElement> | undefined
onChange={(e: React.ChangeEvent<HTMLInputElement>
e) =>order.
order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)
CoMap.$jazz: CoMapJazzApi<{ readonly name: string; } & CoMap> | CoMapJazzApi<{ readonly name: string | undefined; } & CoMap>
Jazz methods for CoMaps are inside this property. This allows CoMaps to be used as plain objects while still having access to Jazz methods, and also doesn't limit which key names can be used inside CoMaps.$jazz.CoMapJazzApi<M extends CoMap>.set<"name">(key: "name", value: string): void
Set a value on the CoMapset("name",e: React.ChangeEvent<HTMLInputElement>
e.React.ChangeEvent<HTMLInputElement>.target: EventTarget & HTMLInputElement
target.HTMLInputElement.value: string
Returns the value of the data at the cursor's current position.value)}React.InputHTMLAttributes<HTMLInputElement>.required?: boolean | undefined
required /> </React.JSX.IntrinsicElements.label: React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>
label> {onSave: ((e: React.FormEvent<HTMLFormElement>) => void) | undefined
onSave && <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
buttonReact.ButtonHTMLAttributes<HTMLButtonElement>.type?: "button" | "reset" | "submit" | undefined
type="submit">Submit</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
button>} </React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>
form> ); }
Writing the edit form
To make the edit form, simply pass the BubbleTeaOrder
. Changes are automatically saved as you type.
// EditOrder.tsx export function
EditOrder(
function EditOrder(props: { id: string; }): React.JSX.Element | undefined
props: {
props: { id: string; }
id: string
id: string }) { constorder =
const order: ({ readonly name: string; } & CoMap) | null | undefined
useCoState<co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>, true>(Schema: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>, id: string | undefined, options?: { ...; } | undefined): ({ ...; } & CoMap) | ... 1 more ... | undefined
React hook for subscribing to CoValues and handling loading states. This hook provides a convenient way to subscribe to CoValues and automatically handles the subscription lifecycle (subscribe on mount, unsubscribe on unmount). It also supports deep loading of nested CoValues through resolve queries.useCoState(BubbleTeaOrder,
const BubbleTeaOrder: co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>
props.
props: { id: string; }
id: string
id); if (!order) return; return <
const order: ({ readonly name: string; } & CoMap) | null | undefined
OrderForm
function OrderForm({ order, }: { order: PartialBubbleTeaOrder; }): React.JSX.Element
order={
order: { readonly name: string | undefined; } & CoMap
order} />; }
const order: { readonly name: string; } & CoMap
Writing the create form
For the create form, we need to:
- Create a partial order.
- Edit the partial order.
- Convert the partial order to a "real" order on submit.
Here's how that looks like:
// CreateOrder.tsx export function
CreateOrder(
function CreateOrder(props: { id: string; }): React.JSX.Element | undefined
props: {
props: { id: string; }
id: string
id: string }) { constorders =
const orders: CoList<({ readonly name: string; } & CoMap) | null> | undefined
useAccountWithSelector<co.Account<{ root: co.Map<{ orders: co.List<co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>>; }, unknown, Account | Group>; profile: co.Profile<BaseProfileShape>; }>, CoList<...> | undefined, { ...; }>(AccountSchema: co.Account<...> | undefined, options: { ...; }): CoList<...> | undefined
React hook for accessing the current user's account with selective data extraction and custom equality checking. This hook extends `useAccount` by allowing you to select only specific parts of the account data through a selector function, which helps reduce unnecessary re-renders by narrowing down the returned data. Additionally, you can provide a custom equality function to further optimize performance by controlling when the component should re-render based on the selected data. The hook automatically handles the subscription lifecycle and supports deep loading of nested CoValues through resolve queries, just like `useAccount`.useAccountWithSelector(JazzAccount, {
const JazzAccount: co.Account<{ root: co.Map<{ orders: co.List<co.Map<{ name: z.z.ZodString; }, unknown, Account | Group>>; }, unknown, Account | Group>; profile: co.Profile<BaseProfileShape>; }>
resolve?: RefsToResolve<{ readonly root: ({ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap) | null; readonly profile: ({ readonly name: string; readonly inbox: string | undefined; readonly inboxInvite: string | undefined; } & CoMap) | null; } & Account, 10, []> | undefined
Resolve query to specify which nested CoValues to load from the accountresolve: {root: {
root?: RefsToResolve<{ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap, 10, [0]> | undefined
orders: true } },
orders?: RefsToResolve<CoList<({ readonly name: string; } & CoMap) | null>, 10, [0, 0]> | undefined
select: (account: CoMapLikeLoaded<{ readonly root: ({ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap) | null; readonly profile: ({ readonly name: string; readonly inbox: string | undefined; readonly inboxInvite: string | undefined; } & CoMap) | null; } & Account, { ...; }, 10, []> | null | undefined) => CoList<...> | undefined
Select which value to return from the account dataselect: (account) =>
account: CoMapLikeLoaded<{ readonly root: ({ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap) | null; readonly profile: ({ readonly name: string; readonly inbox: string | undefined; readonly inboxInvite: string | undefined; } & CoMap) | null; } & Account, { ...; }, 10, []> | null | undefined
account?.
account: CoMapLikeLoaded<{ readonly root: ({ readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap) | null; readonly profile: ({ readonly name: string; readonly inbox: string | undefined; readonly inboxInvite: string | undefined; } & CoMap) | null; } & Account, { ...; }, 10, []> | null | undefined
root.
Account.root: { readonly orders: CoList<({ readonly name: string; } & CoMap) | null>; } & { readonly orders: CoList<({ readonly name: string; } & CoMap) | null> | null; } & CoMap
orders, }); const
orders: CoList<({ readonly name: string; } & CoMap) | null> | undefined
newOrder =
const newOrder: ({ readonly name: string | undefined; } & CoMap) | null | undefined
useCoState<co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>, true>(Schema: co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>, id: string | undefined, options?: { ...; } | undefined): ({ ...; } & CoMap) | ... 1 more ... | undefined
React hook for subscribing to CoValues and handling loading states. This hook provides a convenient way to subscribe to CoValues and automatically handles the subscription lifecycle (subscribe on mount, unsubscribe on unmount). It also supports deep loading of nested CoValues through resolve queries.useCoState(PartialBubbleTeaOrder,
const PartialBubbleTeaOrder: co.Map<{ name: z.ZodOptional<z.z.ZodString>; }, unknown, Account | Group>
props.
props: { id: string; }
id: string
id); if (!newOrder || !
const newOrder: ({ readonly name: string | undefined; } & CoMap) | null | undefined
orders) return; const
const orders: CoList<({ readonly name: string; } & CoMap) | null> | undefined
const handleSave: (e: React.FormEvent<HTMLFormElement>) => void
handleSave = (e: React.FormEvent<HTMLFormElement>
e: React.interface React.FormEvent<T = Element>
FormEvent<HTMLFormElement>) => {e: React.FormEvent<HTMLFormElement>
e.React.BaseSyntheticEvent<Event, EventTarget & HTMLFormElement, EventTarget>.preventDefault(): void
preventDefault(); // Convert to real order and add to the list // Note: the name field is marked as required in the form, so we can assume that has been set in this case // In a more complex form, you would need to validate the partial value before storing itorders.
const orders: CoList<({ readonly name: string; } & CoMap) | null>
$jazz.
CoList<({ readonly name: string; } & CoMap) | null>.$jazz: CoListJazzApi<CoList<({ readonly name: string; } & CoMap) | null>>
CoListJazzApi<CoList<({ readonly name: string; } & CoMap) | null>>.push(...items: (({ readonly name: string; } & CoMap) | CoMapInit<{ readonly name: string; } & CoMap> | null)[]): number
Appends new elements to the end of an array, and returns the new length of the array.push(newOrder as
const newOrder: { readonly name: string | undefined; } & CoMap
BubbleTeaOrder); }; return ( <
type BubbleTeaOrder = { readonly name: string; } & CoMap
OrderForm
function OrderForm({ order, onSave, }: { order: BubbleTeaOrder | PartialBubbleTeaOrder; onSave?: (e: React.FormEvent<HTMLFormElement>) => void; }): React.JSX.Element
order={
order: ({ readonly name: string; } & CoMap) | ({ readonly name: string | undefined; } & CoMap)
newOrder}
const newOrder: { readonly name: string | undefined; } & CoMap
onSave?: ((e: React.FormEvent<HTMLFormElement>) => void) | undefined
onSave={const handleSave: (e: React.FormEvent<HTMLFormElement>) => void
handleSave} /> ); }
Editing with a save button
If you need a save button for editing (rather than automatic saving), you can use Jazz's branching feature. The example app shows how to create a private branch for editing that can be merged back when the user saves:
import { useCoState } from "jazz-tools/react"; import { Group } from "jazz-tools"; import { useState, useMemo } from "react"; export function EditOrderWithSave(props: { id: string }) { // Create a new group for the branch, so that every time we open the edit page, // we create a new private branch const owner = useMemo(() => Group.create(), []); const order = useCoState(BubbleTeaOrder, props.id, { resolve: { addOns: { $each: true }, instructions: true, }, unstable_branch: { name: "edit-order", owner, }, }); function handleSave(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); if (!order) return; // Merge the branch back to the original order.$jazz.unstable_merge(); // Navigate away or show success message } function handleCancel() { // Navigate away without saving - the branch will be discarded } if (!order) return; return <OrderForm order={order} onSave={handleSave} onCancel={handleCancel} />; }
This approach creates a private branch using unstable_branch
with a unique owner group. The user can edit the branch without affecting the original data, and changes are only persisted when they click save via unstable_merge()
.
Important: Version control is currently unstable and we may ship breaking changes in patch releases.
Handling different types of data
Forms can be more complex than just a single string field, so we've put together an example app that shows you how to handle single-select, multi-select, date, boolean inputs, and rich text.