Skip to Content
ExamplesForm state

Form state

A form whose state is a stream:

  • Edits push into formData$ and fold together with scan, seeded from localStorage.
  • Submitting samples the latest data with withLatestFrom.
  • switchMap runs the storage write, emitting saving then saved. Those states drive the button label.

The hook split: field values read useSyncObservable (controlled inputs), while the submit state reads useObservable.

Try it: submit, then reload the sandbox preview. The form comes back from storage.

import {
  type ChangeEvent,
  type SyntheticEvent,
  useState,
} from 'react'
import {
  useObservable,
  useSyncObservable,
} from 'react-rx'
import {
  map,
  scan,
  startWith,
  Subject,
  switchMap,
  withLatestFrom,
} from 'rxjs'

import storage from './storage'

const STORAGE_KEY = '__form-submit-example__'

interface FormValues {
  title: string
  description: string
}

// Form events push into Subjects
const formData$ = new Subject<
  Partial<FormValues>
>()
const submit$ = new Subject<void>()

function FormDataExample() {
  // Form data stream: start from what's in storage, then fold in every edit
  const [data$] = useState(() =>
    storage
      .get(STORAGE_KEY, {
        title: '',
        description: '',
      })
      .pipe(
        switchMap((initial) =>
          formData$.pipe(
            scan(
              (data, update) => ({
                ...data,
                ...update,
              }),
              initial,
            ),
            startWith(initial),
          ),
        ),
      ),
  )

  // Submit state stream: every submit samples the latest form data and
  // switches to the (async) storage write, emitting saving → saved
  const [submitState$] = useState(() =>
    submit$.pipe(
      withLatestFrom(data$),
      map(([, formData]) => formData),
      switchMap((formData) =>
        storage.set(STORAGE_KEY, formData).pipe(
          map(() => ({status: 'saved' as const})),
          startWith({status: 'saving' as const}),
        ),
      ),
      startWith({status: 'unsaved' as const}),
    ),
  )

  // Form field values feed controlled inputs, so they must stay synchronous.
  const formData = useSyncObservable(data$, {
    title: '',
    description: '',
  })
  const submitState = useObservable(
    submitState$,
    {status: 'unsaved' as const},
  )

  const handleChange = (
    e: ChangeEvent<
      HTMLInputElement | HTMLTextAreaElement
    >,
  ) =>
    formData$.next({
      [e.currentTarget.name]:
        e.currentTarget.value,
    })

  const handleSubmit = (
    e: SyntheticEvent<HTMLFormElement>,
  ) => {
    e.preventDefault()
    submit$.next()
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Title
        <input
          type="text"
          name="title"
          value={formData.title}
          onChange={handleChange}
        />
      </label>
      <label>
        Description
        <textarea
          name="description"
          value={formData.description}
          onChange={handleChange}
        />
      </label>
      <button
        disabled={submitState.status === 'saving'}
      >
        {submitState.status === 'saving'
          ? 'Saving…'
          : submitState.status === 'saved'
            ? 'Saved!'
            : 'Save'}
      </button>
    </form>
  )
}

export default FormDataExample

Open on CodeSandboxOpen Sandbox
Last updated on