Expressive MVC
Guides

Forms and Refs

Validation callbacks, debouncing, and mutable references

Forms are where state management accumulates the most junk - fields, validation, touched/dirty tracking, submission state, error messages. Expressive covers all of it with two tools: the set callback form for validation, and the ref instruction for DOM handles.


The set callback

set(initial, callback) creates a writable property whose callback runs on every assignment. The callback can reject the update, accept it silently, or schedule follow-up work.

class LoginForm extends State {
  email = set('', (value, previous) => {
    if (value.length > 120) throw false; // reject
  });
}

Callback behaviors

ActionEffect
throw falseReject the update. Value does not change. No event fires.
throw trueAccept the update silently. Value changes, but no event is emitted.
Return a functionCleanup - runs before the next update (useful for cancellation).
Throw a regular ErrorRethrows to the caller.
Return voidAccept normally.

Note it's throw false, not return false - a return value doesn't reject anything.


Validation

Two different jobs, two different tools. A callback rejects a write - the value never lands. A computed getter reports whether what landed is acceptable - and can drive an error message.

class SignupForm extends State {
  // Reject: a hard cap that should never be stored.
  name = set('', (value) => {
    if (value.length > 50) throw false;
  });

  email = '';
  password = '';

  get emailError() {
    if (!this.email) return;
    return /^.+@.+\..+$/.test(this.email) ? undefined : 'Enter a valid email.';
  }

  get passwordError() {
    if (!this.password) return;
    return this.password.length >= 8 ? undefined : 'At least 8 characters.';
  }

  get valid() {
    return (
      this.name.length > 0 &&
      Boolean(this.email) && !this.emailError &&
      Boolean(this.password) && !this.passwordError
    );
  }

  submit() {
    if (this.valid) createAccount(this.get());
  }
}

Every getter re-runs whenever a dependency changes, and submit reads valid live via this - no stale closures, no dependency arrays.

Don't validate user input by rejecting it. throw false discards the write, so a half-typed value never lands. if (!value.includes('@')) throw false makes the field impossible to type into - the first keystroke is rejected, so it can never reach a value containing @. Reject only what should never be stored at all; express "is this acceptable yet" as a getter, which also gives you somewhere to put the message.

Good to know:
Declaring that from parameter is what makes set computed - a zero-argument function would be a one-shot factory instead. Arity is the dispatch.


Debouncing

Return a cleanup function to cancel in-flight work when the value changes again:

class Search extends State {
  query = set('', (value) => {
    const timer = setTimeout(() => this.performSearch(value), 300);
    return () => clearTimeout(timer);
  });
  results: string[] = [];

  async performSearch(q: string) {
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
    this.results = await res.json();
  }
}

The same pattern works for abort controllers, observer subscriptions, or any resource that should be disposed when a new value arrives.


Change tracking

The callback receives the previous value too, so transitions are easy to detect:

class Field extends State {
  value = set('', (next, prev) => {
    if (next === '' && prev !== '') throw false; // prevent clearing
  });
}

Touched and dirty

Neither needs library machinery - both are ordinary state.

Touched - mark fields on blur, and gate error display on it so pristine fields stay quiet:

class SignupForm extends State {
  email = '';
  touched = {} as Record<string, boolean>;

  touch(field: string) {
    this.touched = { ...this.touched, [field]: true };
  }

  get emailError() {
    if (!this.email) return 'Email is required.';
    return /^.+@.+\..+$/.test(this.email) ? undefined : 'Enter a valid email.';
  }
}

function Signup() {
  const form = SignupForm.use();

  return (
    <>
      <input
        value={form.email}
        onChange={(e) => (form.email = e.target.value)}
        onBlur={() => form.touch('email')}
      />
      {form.touched.email && form.emailError && <p>{form.emailError}</p>}
    </>
  );
}

Reassigning touched rather than mutating it is what dispatches the update - the same rule as any other property.

Dirty - compare against a snapshot taken when the data arrived:

class ProfileForm extends State {
  name = '';
  email = '';
  saved = { name: '', email: '' };

  load(data: { name: string; email: string }) {
    Object.assign(this, data);
    this.saved = { ...data };
  }

  reset() {
    Object.assign(this, this.saved);
  }

  get dirty() {
    return this.name !== this.saved.name || this.email !== this.saved.email;
  }
}

dirty is computed, so a save button disabled={!form.dirty} enables the moment an edit lands - and disables again if the user reverts it by hand.


Refs

The ref instruction creates a mutable holder that does not trigger re-renders on change. It's the useRef equivalent, but declared on the class and wired into the state event stream.

import State, { ref } from '@expressive/react';

class VideoPlayer extends State {
  video = ref<HTMLVideoElement>();

  play() {
    this.video.current?.play();
  }

  pause() {
    this.video.current?.pause();
  }
}

function Player() {
  const { video, play, pause } = VideoPlayer.use();

  return (
    <div>
      <video ref={video}>
        <source src="movie.mp4" />
      </video>
      <button onClick={play}>Play</button>
      <button onClick={pause}>Pause</button>
    </div>
  );
}

Notice the ref object passes straight to JSX ref= - it's callable, so React treats it as a ref callback. Try it live.

Each ref object has:

  • .current - the held value (get/set)
  • .get() - read the current value, or pass a callback to subscribe to changes
  • .is - the parent state instance
  • .key - the property name on the state

Ref with callback

class AutoFocus extends State {
  input = ref<HTMLInputElement>((el) => {
    el.focus();
    return () => el.blur(); // runs when replaced or destroyed
  });
}

The callback fires when the value is set. By default it's skipped for null - pass false as the second argument to fire on null too:

node = ref<HTMLElement>((el) => {
  console.log('value is:', el); // fires for null
}, false);

Ref proxy

Pass this to create ref objects for every enumerable property on the state at once:

class Form extends State {
  name = '';
  email = '';
  refs = ref(this);
}

const form = Form.new();
form.refs.name.current = 'Alice'; // updates form.name
form.refs.email; // ref.Object<string>

Useful for handing ref objects to uncontrolled inputs, or any API that expects { current: T } shapes. Computed getters are included but read-only; this is required - any other object throws.

Custom ref proxy

class Form extends State {
  name = '';
  email = '';
  inputs = ref(this, (key) => createInput(key));
}

The map function runs lazily once per key and its result is cached. This is the hook for building custom controlled-input abstractions.


Complete form example

Putting it together - validation, focus, submission state, all in one class:

import State, { set, ref } from '@expressive/react';

class ContactForm extends State {
  name = set('', (v) => {
    if (v.length > 50) throw false;
  });
  email = '';
  message = '';
  submitting = false;
  submitted = false;
  error = set<string | null>(null);

  firstField = ref<HTMLInputElement>((el) => {
    el.focus();
  });

  get emailError() {
    if (!this.email) return;
    return this.email.includes('@') ? undefined : 'Enter a valid email.';
  }

  get valid() {
    return (
      this.name.length > 0 &&
      Boolean(this.email) && !this.emailError &&
      this.message.length > 0
    );
  }

  async submit() {
    if (!this.valid) return;
    this.submitting = true;
    this.error = null;
    try {
      await api.submitContact(this.get());
      this.submitted = true;
    } catch (e) {
      this.error = (e as Error).message;
    } finally {
      this.submitting = false;
    }
  }
}

function Contact() {
  const form = ContactForm.use();

  if (form.submitted) return <p>Thanks for reaching out.</p>;

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.submit();
      }}>
      <input
        ref={form.firstField}
        value={form.name}
        onChange={(e) => (form.name = e.target.value)}
      />
      <input
        value={form.email}
        onChange={(e) => (form.email = e.target.value)}
      />
      <textarea
        value={form.message}
        onChange={(e) => (form.message = e.target.value)}
      />
      {form.error && <p className="error">{form.error}</p>}
      <button disabled={!form.valid || form.submitting}>
        {form.submitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

Every concern lives in the class; the component just renders it. And because the class is portable, a reusable Form base your whole team extends is one refactor away - that's the featured/forms example. 🚀


Next

On this page