Forms

Forms demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.

App.tsx

import './App.css';

// Form is kept separate as a reusable base class.
import { Form } from "./Form";
import { Preview } from "./Preview";

// Extending Form inherits the plumbing; can focus on values and layout.
class MyForm extends Form {
  firstname = '';
  lastname = '';
  email = '';

  // Methods and behavior are entirely up to you.
  submit(){
    if(!this.firstname || !this.lastname || !this.email){
      alert('Please fill out all fields');
      return;
    }

    alert(`Submitting ${this.firstname} ${this.lastname} with email ${this.email}`);
  }

  // Optional: render makes this self-contained.
  // Without it, children pass through in context of the form.
  render(){
    const { to, submit } = this;

    return (
      <div className="form">
        <h1>Example Form</h1>
        <input ref={to.firstname} placeholder="Firstname" />
        <input ref={to.lastname} placeholder="Lastname" />
        <input ref={to.email} placeholder="Email Address" />
        <button onClick={submit}>Submit</button>
        <Preview />
      </div>
    );
  }
}

// And just like that, we have a form component!
export default MyForm;

App.css

.form {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  width: 100%;
  max-width: 30rem;
  margin: 0 auto;
}

.form h1 {
  margin: 0 0 var(--s2);
}

/* Submit hugs its content rather than stretching to the column width. */
.form button {
  align-self: flex-start;
}

/* Live-preview panel sits a touch below the controls. */
.form pre {
  margin: var(--s3) 0 0;
}

Form.tsx

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

// Base class: binds inputs and provides context to children.
export class Form extends Component {

  // Use `ref` instruction to map over known keys of `this`,
  // running a factory per key. Returns goto `this.to[key]`
  // - for example, a react ref which binds each input.
  to = ref(this, (key) => {
    let reset: (() => void) | undefined;

    return (el: HTMLInputElement | null) => {
      if (reset) reset();
      if (!el) return;
      reset = this.bind(el, key);
    }
  })
  
  // Bind input to its matching property; set name from key.
  // Separate method so subclasses could override.
  public bind(input: HTMLInputElement, key: keyof this) {
    if (!(key in this) || typeof key !== "string")
      throw new Error(`${this} has no property "${String(key)}"`);

    if(!input.name) input.name = key;

    const onInput = () => {
      (this as any)[key] = input.value;
    };
    const unwatch = this.set(key, () => {
      input.value = this[key] as string;
    });

    input.addEventListener('input', onInput);

    return () => {
      input.removeEventListener('input', onInput);
      unwatch();
    };
  }
}

Preview.tsx

import { Form } from "./Form";

// Simple component to show live values for any form.
export function Preview() {
  // Access the nearest Form instance - MyForm included.
  const form = Form.get();
  const values = {} as Record<string, any>;

  for (const key in form)
    // Accessed properties subscribe to updates.
    values[key] = (form as any)[key];

  return (
    <pre>{JSON.stringify(values, null, 2)}</pre>
  );
}