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 Model {
email = set('', (value, previous) => {
if (value.length > 120) throw false; // reject
});
}Callback behaviors
| Action | Effect |
|---|---|
throw false | Reject the update. Value does not change. No event fires. |
throw true | Accept the update silently. Value changes, but no event is emitted. |
| Return a function | Cleanup - runs before the next update (useful for cancellation). |
Throw a regular Error | Rethrows to the caller. |
Return void | Accept normally. |
Note it's
throw false, notreturn 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 Model {
// 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 falsediscards the write, so a half-typed value never lands.if (!value.includes('@')) throw falsemakes 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.
Debouncing
Return a cleanup function to cancel in-flight work when the value changes again:
class Search extends Model {
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 Model {
touched = false;
value = set('', (next, prev) => {
if (next.length > 280) throw false; // never storable - write rejected
if (next !== '' && prev === '') this.touched = true;
});
}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 Signup extends Component {
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.';
}
render() {
const { email, touched, emailError } = this;
return (
<>
<input
value={email}
onChange={(e) => (this.email = e.target.value)}
onBlur={() => this.touch('email')}
/>
{touched.email && emailError && <p>{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 Model {
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 { Component, ref } from '@expressive/mvc';
class VideoPlayer extends Component {
src = '';
video = ref<HTMLVideoElement>();
play() {
this.video.current?.play();
}
pause() {
this.video.current?.pause();
}
render() {
const { src, video, play, pause } = this;
return (
<div>
<video ref={video} src={src} />
<button onClick={play}>Play</button>
<button onClick={pause}>Pause</button>
</div>
);
}
}
<VideoPlayer src="movie.mp4" />;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 instance.key- the property name on the instance
Ref with callback
class AutoFocus extends Model {
input = ref<HTMLInputElement>((el) => {
el.focus();
return () => el.blur(); // runs when replaced, including by null on unmount
});
}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 instance at once:
class Form extends Model {
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. It takes a Model instance - usually this, though another instance works too; a plain object throws.
Custom ref proxy
class Form extends Model {
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 { Component, set, ref } from '@expressive/mvc';
class ContactForm extends Component {
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 {
const { name, email, message } = this;
await api.submitContact({ name, email, message });
this.submitted = true;
} catch (e) {
this.error = (e as Error).message;
} finally {
this.submitting = false;
}
}
render() {
const {
firstField, name, email, message,
error, valid, submitting, submitted, submit
} = this;
if (submitted) return <p>Thanks for reaching out.</p>;
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}>
<input
ref={firstField}
value={name}
onChange={(e) => (this.name = e.target.value)}
/>
<input
value={email}
onChange={(e) => (this.email = e.target.value)}
/>
<textarea
value={message}
onChange={(e) => (this.message = e.target.value)}
/>
{error && <p className="error">{error}</p>}
<button disabled={!valid || submitting}>
{submitting ? 'Sending...' : 'Send'}
</button>
</form>
);
}
}Every concern lives in the class, and
render()is the last method in it - a projection of the fields above. And because the class is portable, a reusableFormbase your whole team extends is one refactor away - that's the featured/forms example. 🚀
Next
- Testing - test
Modelclasses without rendering. - API: Instructions - every
setandrefoverload.