Skip to content

Examples

Every example below is live. Interact with it, then open Source to see the code that powers it.

Counter
Source
import { useIntContent, useRefs } from 'domstatejsx';

export default function Counter() {
  const [countSpan] = useRefs();
  const [, setCount] = useIntContent(countSpan);

  function handleClick() {
    setCount((prev) => prev + 1);
  }

  return (
    <>
      <div>
        <button onClick={handleClick}>Click me</button>
      </div>
      <div>
        Count: <span ref={countSpan}>0</span>
      </div>
    </>
  );
}

A full todo application with add, delete, edit, drag-and-drop reordering and localStorage persistence.

Todos
Source
import './examples.css';
import {
  combineHooks,
  createContext,
  useCheckbox,
  useClassBoolean,
  useIntContent,
  useTextContent,
  useList,
  useRefProxy,
  useContextUp,
} from 'domstatejsx';

export default function App() {
  const refs = useRefProxy();

  const [, setTotal] = useIntContent(refs.totalSpan);
  const [, setDone] = useIntContent(refs.doneSpan);

  const [getTodos, addTodos] = combineHooks(
    useList(refs.todoList, Todo),
    [, (...args) => setTotal((prev) => prev + args.length)],
    [
      ,
      (...args) =>
        setDone((prev) => prev + args.filter(({ done }) => done).length),
    ],
    [, save],
  );

  function handleAdd(event) {
    event.preventDefault();
    const input = event.target['text'];
    if (!input.value) return;
    addTodos({ text: input.value });
    input.value = '';
  }

  function onDelete(wasDone) {
    setTotal((prev) => prev - 1);
    setDone((prev) => prev - (wasDone ? 1 : 0));
  }

  function onDone(done) {
    setDone((prev) => prev + (done ? 1 : -1));
  }

  function onToggleEdit(id) {
    getTodos().forEach(({ context: { id: todoId, toggleForm } }) =>
      toggleForm(id === todoId ? null : false),
    );
  }

  function move(srcId, dstId) {
    const srcIndex = getTodos().findIndex(
      ({ context: { id } }) => id === srcId,
    );
    const dstIndex = getTodos().findIndex(
      ({ context: { id } }) => id === dstId,
    );

    const srcNode = getTodos()[srcIndex].current;
    const dstNode = getTodos()[dstIndex].current;

    if (srcIndex > dstIndex) {
      refs.todoList.current.insertBefore(srcNode, dstNode);
    } else {
      refs.todoList.current.insertBefore(srcNode, dstNode.nextSibling);
    }
  }

  function handleShuffle() {
    for (let i = getTodos().length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      move(getTodos()[i].context.id, getTodos()[j].context.id);
    }
  }

  function save() {
    localStorage.setItem(
      'todos',
      JSON.stringify(
        getTodos().map(({ context: { getText, isDone } }) => ({
          text: getText(),
          done: isDone(),
        })),
      ),
    );
  }

  setTimeout(() => {
    const data = localStorage.getItem('todos');
    if (!data) return;
    addTodos(...JSON.parse(data));
  }, 0);

  return (
    <App.Context.Provider
      value={{ onDelete, onDone, onToggleEdit, move, save }}
    >
      <div style={{ maxWidth: '48rem' }}>
        <h1>My Todo app</h1>
        <h2>
          Summary{' '}
          <small>
            Total: <span ref={refs.totalSpan}>0</span>, Done:{' '}
            <span ref={refs.doneSpan}>0</span>{' '}
            <button class="dx-btn" onClick={handleShuffle}>
              Shuffle
            </button>
          </small>
        </h2>
        <form onSubmit={handleAdd} class="dx-box dx-row" style={{ marginTop: 8 }}>
          <input class="dx-input" name="text" autoFocus />
          <button class="dx-btn">Add</button>
        </form>
        <ul ref={refs.todoList} style={{ padding: '8px 0' }} />
      </div>
    </App.Context.Provider>
  );
}
App.Context = createContext();

function Todo({ text, done = false }) {
  const refs = useRefProxy();

  const [, setIsEditing] = combineHooks(
    useClassBoolean(refs.textSpan, 'dx-hidden', null),
    useClassBoolean(refs.textForm, null, 'dx-hidden'),
    [
      ,
      (value) => {
        if (value) refs.textInput.current.focus();
      },
    ],
  );

  const [getText, setText] = combineHooks(
    useTextContent(refs.textSpan),
    [, () => useContextUp(refs.head.current, App.Context).save()],
    [, () => setIsEditing(false)],
  );

  const [isDone, setIsDone] = combineHooks(
    useCheckbox(refs.doneCheckbox),
    useClassBoolean(refs.textSpan, 'dx-todo-done', null),
    [
      ,
      (value) => {
        const { onDone, save } = useContextUp(refs.head.current, App.Context);
        onDone(value);
        save();
      },
    ],
  );

  const [, setDotted] = useClassBoolean(refs.label, 'dragging', null);

  function handleDelete() {
    const { onDelete, save } = useContextUp(refs.head.current, App.Context);
    onDelete(isDone());
    refs.head.current.remove();
    save();
  }

  const id = crypto.randomUUID();
  function handleToggleEdit() {
    useContextUp(refs.head.current, App.Context).onToggleEdit(id);
  }

  function toggleForm(value) {
    setIsEditing(value === null ? (prev) => !prev : value);
  }

  function handleSubmitEdit(event) {
    event.preventDefault();
    const input = event.target['text'];
    if (!input.value) return;
    setText(input.value);
  }

  return (
    <Todo.Context.Provider
      value={{ getText, isDone, id, toggleForm }}
      ref={refs.head}
    >
      <li
        draggable="true"
        onDragStart={(e) => e.dataTransfer.setData('text/plain', id)}
        onDragEnter={() => setDotted(true)}
        onDragLeave={() => setDotted(false)}
        onDragOver={(e) => e.preventDefault()}
        onDrop={(e) => {
          e.preventDefault();
          useContextUp(refs.head.current, App.Context).move(
            e.dataTransfer.getData('text/plain'),
            id,
          );
          setDotted(false);
        }}
      >
        <label
          class="dx-todo-row"
          style={{ border: '2px solid transparent' }}
          ref={refs.label}
        >
          <div class="dx-row" style={{ paddingRight: 8 }}>
            <input
              type="checkbox"
              checked={done}
              onChange={(e) => setIsDone(e.target.checked)}
              ref={refs.doneCheckbox}
            />
            <button class="dx-btn" onClick={handleDelete}>

            </button>
            <button class="dx-btn" onClick={handleToggleEdit}>

            </button>
          </div>
          <span ref={refs.textSpan} class={done ? 'dx-todo-done' : null}>
            {text}
          </span>
          <form
            onSubmit={handleSubmitEdit}
            ref={refs.textForm}
            class="dx-hidden dx-row"
          >
            <input class="dx-input" name="text" value={text} ref={refs.textInput} />
            <button class="dx-btn">Save</button>
          </form>
        </label>
      </li>
    </Todo.Context.Provider>
  );
}
Todo.Context = createContext();

Single and multi-open accordions, controlled both from inside and outside.

Accordion
Source
import './examples.css';
import {
  combineHooks,
  createContext,
  useContextDown,
  useContextUp,
  useRefProxy,
  useStyleBoolean,
} from 'domstatejsx';
import { v4 as uuid4 } from 'uuid';

export default function App() {
  return (
    <div class="dx-col">
      <h1>No multi</h1>
      <Demo />
      <h1>Multi</h1>
      <Demo multi />
    </div>
  );
}

const AccordionContext = createContext();
const AccordionItemContext = createContext();

export function Demo({ multi = false }) {
  const refs = useRefProxy();
  const AccItem = Accordion.Item;

  function getButton(id) {
    return ({ toggle, isOpen }) => (
      <button class="dx-btn" onClick={toggle}>
        trigger {id} {isOpen ? '^' : 'v'}
      </button>
    );
  }

  return (
    <div class="dx-col">
      <div class="dx-row">
        {multi && (
          <button class="dx-btn" onClick={() => refs.accordion.context.expandAll()}>
            Expand All
          </button>
        )}
        <button class="dx-btn" onClick={() => refs.accordion.context.collapseAll()}>
          Collapse all
        </button>
        <button class="dx-btn" onClick={() => refs.accordion.context.triggerItem('2')}>
          Trigger item 2
        </button>
        <button class="dx-btn" onClick={() => refs.accordion.context.triggerItem('2', true)}>
          Show item 2
        </button>
      </div>
      <Accordion multi={multi} ref={refs.accordion} class="dx-col">
        <AccItem renderTrigger={getButton('1')} id="1" class="dx-box">
          Content 1
        </AccItem>
        <AccItem renderTrigger={getButton('2')} id="2" class="dx-box">
          Content 2
        </AccItem>
        <AccItem renderTrigger={getButton('3')} id="3" class="dx-box">
          Content 3
        </AccItem>
      </Accordion>
    </div>
  );
}

function Accordion({ children, multi = false, class: className, style }) {
  const refs = useRefProxy();

  function triggerItem(id, show = null) {
    const items = useContextDown(refs.head.current, AccordionItemContext);

    if (!multi) {
      items
        .filter(({ id: itemId }) => itemId !== id)
        .forEach(({ setContentHidden }) => setContentHidden(true));
    }

    items
      .find(({ id: itemId }) => id === itemId)
      .setContentHidden(show === null ? (prev) => !prev : !show);
  }

  function expandAll() {
    if (!multi) return;
    useContextDown(refs.head.current, AccordionItemContext).forEach(
      ({ setContentHidden }) => setContentHidden(false),
    );
  }

  function collapseAll() {
    useContextDown(refs.head.current, AccordionItemContext).forEach(
      ({ setContentHidden }) => setContentHidden(true),
    );
  }

  return (
    <AccordionContext.Provider
      value={{ triggerItem, expandAll, collapseAll }}
      ref={refs.head}
    >
      <div class={className} style={style}>
        {children}
      </div>
    </AccordionContext.Provider>
  );
}
Accordion.Context = AccordionContext;

function AccordionItem({
  trigger,
  renderTrigger,
  id: defaultId,
  defaultHidden = true,
  class: className,
  style,
  children,
}) {
  const refs = useRefProxy();

  const [isContentHidden, setContentHidden] = combineHooks(
    useStyleBoolean(refs.content, 'display', 'none', null),
    [, rerenderTrigger],
  );

  const id = defaultId || uuid4();
  function handleTrigger() {
    const { triggerItem } = useContextUp(refs.head.current, AccordionContext);
    triggerItem(id);
  }

  function rerenderTrigger() {
    if (!renderTrigger) return;
    refs.trigger.current.replaceChildren(
      renderTrigger({ toggle: handleTrigger, isOpen: !isContentHidden() }),
    );
  }
  setTimeout(rerenderTrigger, 0);

  return (
    <AccordionItemContext.Provider
      value={{ id, setContentHidden }}
      ref={refs.head}
    >
      <div class={className} style={style}>
        <div ref={refs.trigger}>
          {renderTrigger ? (
            renderTrigger({ toggle: handleTrigger, isOpen: false })
          ) : (
            <button onClick={handleTrigger}>{trigger}</button>
          )}
        </div>
        <div
          style={{ display: defaultHidden ? 'none' : undefined }}
          ref={refs.content}
        >
          {children}
        </div>
      </div>
    </AccordionItemContext.Provider>
  );
}
Accordion.Item = AccordionItem;
Accordion.Item.Context = AccordionItemContext;

Form creation, validation and error display with useForm.

Form with validation
Source
import './examples.css';
import { useForm, useRefProxy, useTextContent } from 'domstatejsx';
import Radio from './Radio.jsx';

export default function App() {
  const refs = useRefProxy();
  const [, setPre] = useTextContent(refs.pre);

  const { registerForm, register, registerError } = useForm({
    onSuccess: async (data) => {
      setPre('Success: ' + JSON.stringify(data, null, 2));
    },
    onError: async (errors) => {
      setPre('Errors: ' + JSON.stringify(errors, null, 2));
    },
    validate: async ({ username, gender }) => {
      if (username === 'Bill' && gender === 'female') {
        throw new Error("Bill is a boy's name");
      }
    },
  });

  return (
    <>
      <form {...registerForm()}>
        <p>
          Username:{' '}
          <input class="dx-input" autoFocus {...register('username', { required: true })} />
        </p>
        <p class="dx-red" style={{ display: 'none' }} {...registerError('username')} />
        <p>
          Gender:{' '}
          <Radio
            options={[
              ['male', 'Male'],
              ['female', 'Female'],
            ]}
            {...register('gender', { required: true })}
          />
        </p>
        <p class="dx-red" style={{ display: 'none' }} {...registerError('gender')} />
        <p class="dx-red" style={{ display: 'none' }} {...registerError()} />
        <p>
          <button class="dx-btn">Submit</button>
        </p>
      </form>
      <p>
        <pre class="dx-pre" ref={refs.pre} />
      </p>
    </>
  );
}

A paginated list that fetches data on demand.

Pagination
Source
import './examples.css';
import {
  combineHooks,
  createContext,
  useList,
  usePropertyBoolean,
  useQuery,
  useRefProxy,
  useStyleBoolean,
} from 'domstatejsx';

async function fakeGet(page) {
  await new Promise((resolve) => setTimeout(resolve, 400));
  return [...Array(10)].map((_, i) => `product ${(page - 1) * 10 + i + 1}`);
}

export default function App() {
  const refs = useRefProxy();

  const [, setPrevDisabled] = usePropertyBoolean(
    refs.prevButton,
    'disabled',
    true,
    false,
  );
  const [, setLoading] = combineHooks(
    useStyleBoolean(refs.spinner, 'display', null, 'none'),
    useStyleBoolean(refs.pageList, 'display', 'none', null),
    usePropertyBoolean(refs.nextButton, 'disabled', true, false),
  );

  const { refetch } = useQuery({
    queryFn: (page = 1) => fakeGet(page),
    onStart: () => setLoading(true),
    onEnd: () => setLoading(false),
    onSuccess: (data) => {
      const { addPage, getPageNumber } = refs.pageList.context;
      addPage({ data });
      setPrevDisabled(getPageNumber() === 0);
    },
  });

  function handleNext() {
    const { getPageNumber, getPageCount, showPage } = refs.pageList.context;
    const currentPageNumber = getPageNumber();
    if (currentPageNumber === getPageCount() - 1) {
      refetch(currentPageNumber + 2);
    } else {
      showPage((prev) => prev + 1);
    }
    setPrevDisabled(false);
  }

  function handlePrevious() {
    const { getPageNumber, showPage } = refs.pageList.context;
    showPage((prev) => prev - 1);
    setPrevDisabled(getPageNumber() === 0);
  }

  return (
    <>
      <div class="dx-row">
        <button class="dx-btn" onClick={handlePrevious} disabled ref={refs.prevButton}>
          Prev
        </button>
        <button class="dx-btn" onClick={handleNext} ref={refs.nextButton}>
          Next
        </button>
      </div>
      <div class="dx-loading" ref={refs.spinner}>
        Loading...
      </div>
      <PageList ref={refs.pageList} />
    </>
  );
}

function PageList() {
  const refs = useRefProxy();
  const [getPages, addPage] = combineHooks(useList(refs.head, Page), [
    ,
    () => showPage(getPageCount() - 1),
  ]);

  function getPageNumber() {
    return getPages().findIndex(({ context: { isHidden } }) => !isHidden());
  }

  function getPageCount() {
    return getPages().length;
  }

  function showPage(indexOrFunc) {
    const index =
      indexOrFunc instanceof Function ? indexOrFunc(getPageNumber()) : indexOrFunc;
    getPages().forEach(({ context: { setIsHidden } }, i) =>
      setIsHidden(i !== index),
    );
  }

  return (
    <PageList.Context.Provider
      value={{ getPageNumber, getPageCount, addPage, showPage }}
      ref={refs.head}
    >
      <div class="dx-pagelist" />
    </PageList.Context.Provider>
  );
}
PageList.Context = createContext();

function Page({ data }) {
  const refs = useRefProxy();
  const [isHidden, setIsHidden] = useStyleBoolean(
    refs.head,
    'display',
    'none',
    null,
  );

  return (
    <Page.Context.Provider value={{ isHidden, setIsHidden }} ref={refs.head}>
      <ul class="dx-list">
        {data.map((message) => (
          <li>{message}</li>
        ))}
      </ul>
    </Page.Context.Provider>
  );
}
Page.Context = createContext();

A simple pager driven by a reusable component’s exposed context.

Pager
Source
import './examples.css';
import {
  createContext,
  usePropertyBoolean,
  useRefProxy,
  useRefs,
} from 'domstatejsx';

export default function App() {
  const refs = useRefProxy();

  const [, setPreviousDisabled] = usePropertyBoolean(
    refs.previousButton,
    'disabled',
    true,
    false,
  );
  const [, setNextDisabled] = usePropertyBoolean(
    refs.nextButton,
    'disabled',
    true,
    false,
  );

  function handlePrevious() {
    refs.pager.context.setPreviousPage();
    _setDisabled();
  }

  function handleNext() {
    refs.pager.context.setNextPage();
    _setDisabled();
  }

  function _setDisabled() {
    setPreviousDisabled(!refs.pager.context.hasPrevious());
    setNextDisabled(!refs.pager.context.hasNext());
  }

  setTimeout(_setDisabled, 0);

  return (
    <>
      <Pager ref={refs.pager}>
        <div class="dx-col">
          <div>Page 1</div>
          <div>Page 2</div>
          <div>Page 3</div>
          <div>Page 4</div>
          <div>Page 5</div>
        </div>
      </Pager>
      <div class="dx-row" style={{ marginTop: 8 }}>
        <button class="dx-btn" onClick={handlePrevious} ref={refs.previousButton}>
          Previous
        </button>
        <button class="dx-btn" onClick={handleNext} ref={refs.nextButton}>
          Next
        </button>
      </div>
    </>
  );
}

function Pager({ children, defaultPage = 0 }) {
  const [head] = useRefs();

  function getPage() {
    return [...head.current.children].findIndex(
      (node) => node.style.display !== 'none',
    );
  }

  function setPage(page) {
    [...head.current.children].forEach((node, i) => {
      if (i === page) node.style.display = '';
      else node.style.display = 'none';
    });
  }

  function getPageCount() {
    return head.current.children.length;
  }

  function setPreviousPage() {
    return setPage(getPage() - 1);
  }

  function setNextPage() {
    return setPage(getPage() + 1);
  }

  function hasPrevious() {
    return getPage() > 0;
  }

  function hasNext() {
    return getPage() < getPageCount() - 1;
  }

  setTimeout(() => setPage(defaultPage), 0);

  return (
    <Pager.Context.Provider
      value={{
        getPage,
        setPage,
        getPageCount,
        setNextPage,
        setPreviousPage,
        hasPrevious,
        hasNext,
      }}
      ref={head}
    >
      {children}
    </Pager.Context.Provider>
  );
}
Pager.Context = createContext();

A slide-out panel.

Panel
Source
import './examples.css';
import { createContext, useRefProxy, useStyleBoolean } from 'domstatejsx';

export default function App() {
  return (
    <Panel
      renderTrigger={(open) => (
        <button class="dx-btn" onClick={open}>
          Open panel
        </button>
      )}
      render={(close) => (
        <>
          This is the panel
          <button class="dx-btn" onClick={close}>
            Close
          </button>
        </>
      )}
    />
  );
}

function Panel({ renderTrigger, render, defaultIsOpen = false }) {
  const refs = useRefProxy();

  const [, setIsOpen] = useStyleBoolean(refs.dark, 'display', null, 'none');
  const [, setPanelOpen] = useStyleBoolean(
    refs.panel,
    'transform',
    'translateX(0)',
    'translateX(100%)',
  );

  return (
    <Panel.Context.Provider value={{ get: () => {}, set: setIsOpen }}>
      <div>
        {renderTrigger && renderTrigger(() => setIsOpen(true))}
        <div
          class="dx-backdrop"
          style={{ display: defaultIsOpen ? '' : 'none' }}
          onClick={() => setIsOpen(false)}
          ref={refs.dark}
        />
        <div
          class="dx-panel"
          style={{
            transform: defaultIsOpen ? 'translateX(0)' : 'translateX(100%)',
          }}
          ref={refs.panel}
        >
          {render(() => {
            setIsOpen(false);
            setPanelOpen(false);
          })}
        </div>
      </div>
    </Panel.Context.Provider>
  );
}
Panel.Context = createContext();

Controlling a custom radio group through its context.

Controlled radio
Source
import './examples.css';
import { useTextContent, useControlledInput, useRefProxy } from 'domstatejsx';
import Radio from './Radio.jsx';

export default function App() {
  const refs = useRefProxy();

  const [getRadio, setRadio] = useControlledInput(refs.radio);
  const [, setSpan] = useTextContent(refs.span);

  function refreshSpan() {
    setSpan(getRadio());
  }

  setTimeout(refreshSpan, 0);

  return (
    <>
      <Radio
        defaultValue={3}
        onChange={setSpan}
        options={[
          [0, 'Zero'],
          [1, 'One'],
          [2, 'Two'],
          [3, 'Three'],
        ]}
        ref={refs.radio}
      />
      <p>
        <button
          class="dx-btn"
          onClick={() => {
            setRadio(2);
            refreshSpan();
          }}
        >
          Select "two"
        </button>
      </p>
      <p>
        Selected Value: <span ref={refs.span} />
      </p>
    </>
  );
}

A recursive JSON editor built from smaller controlled components.

JSON editor
Source
import './examples.css';
import {
  runOnlyLast,
  createContext,
  useClassBoolean,
  useControlledInput,
  useList,
  useNumberInput,
  useRefProxy,
  useTextContent,
  useTextInput,
} from 'domstatejsx';
import Radio from './Radio.jsx';

export default function App() {
  const refs = useRefProxy();
  const [getJson, setJson] = useControlledInput(refs.jsonEdit);
  const [, setPre] = useTextContent(refs.pre);

  function renderPre(value = undefined) {
    setTimeout(() => {
      const serialized = JSON.stringify(
        value === undefined ? getJson() : value,
        null,
        2,
      );
      setPre(serialized);
    }, 0);
  }
  renderPre();

  return (
    <>
      <div class="dx-row">
        <button
          class="dx-btn"
          onClick={() => {
            setJson(null);
            renderPre();
          }}
        >
          Set null
        </button>
        <button
          class="dx-btn"
          onClick={() => {
            setJson('hello world');
            renderPre();
          }}
        >
          Set string
        </button>
        <button
          class="dx-btn"
          onClick={() => {
            setJson([1, 'two', 3, 'four']);
            renderPre();
          }}
        >
          Set array
        </button>
        <button
          class="dx-btn"
          onClick={() => {
            setJson({
              source_string: 'Hello world',
              source_language: 'en',
              target_language: 'el',
              glossary: [{ source: 'world', pos: 'noun', translation: 'κόσμος' }],
              industry: 'fintech',
            });
            renderPre();
          }}
        >
          Set prompt context
        </button>
      </div>
      <div style={{ marginTop: 8 }}>
        <JsonEdit onChange={renderPre} ref={refs.jsonEdit} />
      </div>
      <pre class="dx-pre" ref={refs.pre} />
    </>
  );
}

function JsonEdit({ onChange = () => {}, defaultValue = null }) {
  const refs = useRefProxy();

  const [getType, setType] = useTextInput(refs.typeSelect);
  function get() {
    const type = getType();
    if (type === 'null') return null;
    else if (type === 'boolean') return getBoolean();
    else if (type === 'number') return getNumber();
    else if (type === 'string') return getString();
    else if (type === 'array') return getArray();
    else if (type === 'object') return getObject();
  }

  function set(value) {
    if (value === null) {
      setType('null');
      toggleVisible('null');
    } else if (typeof value === 'boolean') {
      setType('boolean');
      setBoolean(value);
      toggleVisible('boolean');
    } else if (typeof value === 'number') {
      setType('number');
      setNumber(value);
      toggleVisible('number');
    } else if (typeof value === 'string') {
      setType('string');
      setString(value);
      toggleVisible('string');
    } else if (Array.isArray(value)) {
      setType('array');
      setArray(value);
      toggleVisible('array');
    } else if (isObject(value)) {
      setType('object');
      setObject(value);
      toggleVisible('object');
    }
  }

  function getActiveInput() {
    const type = getType();
    if (type === 'number') return refs.numberInput.current;
    else if (type === 'string') return refs.stringInput.current;
  }

  function toggleVisible(type) {
    [
      ['null', () => {}],
      ['boolean', setBooleanVisible],
      ['number', setNumberVisible],
      ['string', setStringVisible],
      ['array', setArrayVisible],
      ['object', setObjectVisible],
    ].forEach(([t, setVisible]) => {
      setVisible(t === type);
    });
  }

  function handleTypeChange(type) {
    if (type === 'boolean') {
      setBoolean(false);
      onChange(false);
    } else if (type === 'number') {
      setNumber(0);
      onChange(0);
    } else if (type === 'string') {
      setString('');
      onChange('');
    } else if (type === 'array') {
      setArray([]);
      onChange([]);
    } else if (type === 'object') {
      setObject({});
      onChange({});
    }
    toggleVisible(type);
    runOnlyLast(() => getActiveInput()?.focus());
  }

  const [, setBooleanVisible] = useClassBoolean(refs.booleanSpan, null, 'dx-hidden');
  const [getBoolean, setBoolean] = useControlledInput(refs.booleanRadio);

  const [, setNumberVisible] = useClassBoolean(refs.numberSpan, null, 'dx-hidden');
  const [getNumber, setNumber] = useNumberInput(refs.numberInput);

  const [, setStringVisible] = useClassBoolean(refs.stringSpan, null, 'dx-hidden');
  const [getString, setString] = useTextInput(refs.stringInput);

  const [, setArrayVisible] = useClassBoolean(refs.arrayDiv, null, 'dx-hidden');
  const [getArrayRefs, addArrayItems, resetArrayItems] = useList(
    refs.arrayList,
    ArrayItem,
  );
  function getArray() {
    return getArrayRefs().map(({ context: { get } }) => get());
  }
  function setArray(items) {
    resetArrayItems(
      ...items.map((item) => ({
        defaultValue: item,
        onChange: () => onChange(items),
      })),
    );
  }
  function handleAddItemToArray() {
    addArrayItems({ defaultValue: '', onChange: () => onChange(getArray()) });
    onChange(getArray());
    getArrayRefs().at(-1)?.context?.focus();
  }

  const [, setObjectVisible] = useClassBoolean(refs.objectDiv, null, 'dx-hidden');
  const [getObjectRefs, addObjectItems, resetObjectItems] = useList(
    refs.objectList,
    ObjectItem,
  );
  function getObject() {
    return Object.fromEntries(
      getObjectRefs().map(({ context: { get } }) => get()),
    );
  }
  function setObject(value) {
    resetObjectItems(
      ...Object.entries(value).map(([key, item]) => ({
        defaultKey: key,
        defaultValue: item,
        onChange: () => onChange(getObject()),
      })),
    );
  }
  function handleAddItemToObject() {
    addObjectItems({
      defaultKey: '',
      defaultValue: '',
      onChange: () => onChange(getObject()),
    });
    onChange(getObject());
    getObjectRefs().at(-1)?.context?.focus();
  }

  return (
    <JsonEdit.Context.Provider value={{ get, set, getActiveInput }}>
      <div class="dx-box dx-row" style={{ alignItems: 'flex-start' }}>
        <div>
          <select
            onChange={(e) => handleTypeChange(e.target.value)}
            class="dx-input"
            ref={refs.typeSelect}
          >
            <option value="null" selected={defaultValue === null}>
              null
            </option>
            <option value="boolean" selected={typeof defaultValue === 'boolean'}>
              boolean
            </option>
            <option value="number" selected={typeof defaultValue === 'number'}>
              number
            </option>
            <option value="string" selected={typeof defaultValue === 'string'}>
              string
            </option>
            <option value="array" selected={Array.isArray(defaultValue)}>
              array
            </option>
            <option value="object" selected={isObject(defaultValue)}>
              object
            </option>
          </select>
        </div>

        <span
          class={typeof defaultValue === 'boolean' ? '' : 'dx-hidden'}
          ref={refs.booleanSpan}
        >
          <Radio
            options={[
              [false, 'false'],
              [true, 'true'],
            ]}
            defaultValue={typeof defaultValue === 'boolean' ? defaultValue : undefined}
            onChange={onChange}
            ref={refs.booleanRadio}
          />
        </span>

        <span
          class={typeof defaultValue === 'number' ? '' : 'dx-hidden'}
          ref={refs.numberSpan}
        >
          <input
            type="number"
            class="dx-input"
            value={typeof defaultValue === 'number' ? defaultValue : undefined}
            onKeyUp={(e) => onChange(parseFloat(e.target.value))}
            onChange={(e) => onChange(parseFloat(e.target.value))}
            ref={refs.numberInput}
          />
        </span>

        <span
          class={typeof defaultValue === 'string' ? '' : 'dx-hidden'}
          ref={refs.stringSpan}
        >
          <input
            class="dx-input"
            value={typeof defaultValue === 'string' ? defaultValue : undefined}
            onKeyUp={(e) => onChange(e.target.value)}
            ref={refs.stringInput}
          />
        </span>

        <div
          class={`dx-col ${Array.isArray(defaultValue) ? '' : 'dx-hidden'}`}
          ref={refs.arrayDiv}
        >
          <div class="dx-col" ref={refs.arrayList}>
            {Array.isArray(defaultValue) &&
              defaultValue.map((item) => (
                <ArrayItem defaultValue={item} onChange={onChange} />
              ))}
          </div>
          <button class="dx-btn" onClick={handleAddItemToArray}>
            Add
          </button>
        </div>

        <div
          class={`dx-col ${isObject(defaultValue) ? '' : 'dx-hidden'}`}
          ref={refs.objectDiv}
        >
          <div class="dx-col" ref={refs.objectList}>
            {isObject(defaultValue) &&
              Object.entries(defaultValue).map(([key, value]) => (
                <ObjectItem
                  defaultKey={key}
                  defaultValue={value}
                  onChange={onChange}
                />
              ))}
          </div>
          <button class="dx-btn" onClick={handleAddItemToObject}>
            Add
          </button>
        </div>
      </div>
    </JsonEdit.Context.Provider>
  );
}
JsonEdit.Context = createContext();

function ArrayItem({ defaultValue, onChange }) {
  const refs = useRefProxy();

  function get() {
    return refs.jsonEdit.context.get();
  }

  function focus() {
    runOnlyLast(() => refs.jsonEdit.context.getActiveInput()?.focus());
  }

  function handleDelete() {
    refs.head.current.remove();
    setTimeout(() => onChange(get()), 0);
  }

  return (
    <ArrayItem.Context.Provider value={{ get, focus }} ref={refs.head}>
      <div class="dx-row">
        <button class="dx-btn" onClick={handleDelete}>
          Delete
        </button>
        <JsonEdit defaultValue={defaultValue} onChange={onChange} ref={refs.jsonEdit} />
      </div>
    </ArrayItem.Context.Provider>
  );
}
ArrayItem.Context = createContext();

function ObjectItem({ defaultKey, defaultValue, onChange }) {
  const refs = useRefProxy();

  const [getKey] = useTextInput(refs.keyInput);

  function get() {
    return [getKey(), refs.jsonEdit.context.get()];
  }

  function focus() {
    runOnlyLast(() => refs.keyInput.current.focus());
  }

  function handleDelete() {
    refs.head.current.remove();
    setTimeout(() => onChange(get()), 0);
  }

  focus();

  return (
    <ObjectItem.Context.Provider value={{ get, focus }} ref={refs.head}>
      <div class="dx-row">
        <button class="dx-btn" onClick={handleDelete}>
          Delete
        </button>
        <input
          class="dx-input"
          value={defaultKey}
          onChange={() => onChange(get())}
          onKeyUp={() => onChange(get())}
          ref={refs.keyInput}
        />
        <JsonEdit defaultValue={defaultValue} onChange={onChange} ref={refs.jsonEdit} />
      </div>
    </ObjectItem.Context.Provider>
  );
}
ObjectItem.Context = createContext();

function isObject(value) {
  return typeof value === 'object' && !Array.isArray(value) && value !== null;
}

Data fetching and mutations against a fake API.

Fake API
Source
import './examples.css';
import {
  useForm,
  useMutation,
  usePropertyBoolean,
  useQuery,
  useRefProxy,
  useStyleBoolean,
} from 'domstatejsx';

const fakeApi = {
  messages: ['a@b.c', 'd@e.f'],
  get: async () => {
    await new Promise((resolve) => setTimeout(resolve, 400));
    return fakeApi.messages;
  },
  post: async (newMessage) => {
    await new Promise((resolve) => setTimeout(resolve, 400));
    if (newMessage.indexOf('@') === -1) {
      throw new Error('Invalid email address');
    }
    fakeApi.messages.push(newMessage);
  },
};

export default function App() {
  const refs = useRefProxy();

  const [, setQueryIsLoading] = useStyleBoolean(
    refs.loading,
    'display',
    null,
    'none',
  );
  const [, setFormIsLoading] = usePropertyBoolean(
    refs.submit,
    'disabled',
    true,
    false,
  );

  const { refetch } = useQuery({
    onStart: () => setQueryIsLoading(true),
    queryFn: fakeApi.get,
    onSuccess: (messages) => {
      refs.ul.current.replaceChildren(...messages.map((message) => <li>{message}</li>));
    },
    onEnd: () => setQueryIsLoading(false),
  });

  const { mutate } = useMutation({
    mutationFn: fakeApi.post,
    onSuccess: () => {
      reset();
      refs.ul.current.replaceChildren();
      refetch();
    },
  });

  const { registerForm, register, registerError, reset } = useForm({
    onStart: async () => {
      setFormIsLoading(true);
    },
    onSubmit: async (data) => {
      await mutate(data.message);
    },
    onEnd: async () => {
      setFormIsLoading(false);
    },
  });

  return (
    <>
      <p class="dx-loading" style={{ display: 'none' }} ref={refs.loading}>
        Loading...
      </p>
      <ul class="dx-list" ref={refs.ul} />
      <p>
        <button
          class="dx-btn"
          onClick={() => {
            refs.ul.current.replaceChildren();
            refetch();
          }}
        >
          Refetch
        </button>
      </p>
      <form {...registerForm()}>
        <p>
          Message:{' '}
          <input class="dx-input" {...register('message', { required: true })} />
        </p>
        <p class="dx-red" style={{ display: 'none' }} {...registerError()} />
        <p class="dx-red" style={{ display: 'none' }} {...registerError('message')} />
        <p>
          <button class="dx-btn" ref={refs.submit}>
            Submit
          </button>
        </p>
      </form>
    </>
  );
}

The router drives the browser URL, so it runs on a dedicated page instead of inline here.