Examples
Every example below is live. Interact with it, then open Source to see the code that powers it.
Counter
Section titled “Counter” 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(); Accordion
Section titled “Accordion”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>
</>
);
} Pagination
Section titled “Pagination”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(); Controlled inputs
Section titled “Controlled inputs”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>
</>
);
} JSON editor
Section titled “JSON editor”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;
} Query & mutation
Section titled “Query & mutation”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>
</>
);
} Routing
Section titled “Routing”The router drives the browser URL, so it runs on a dedicated page instead of inline here.