domstatejsx
v0.0.22
Published
JSX component library for applications whose state lives in the DOM
Maintainers
Readme
Overview
domstatejsx is a web frontend library that allows building applications with
the following features:
- Render DOM elements with JSX
- Keep your application's state in the DOM. No need to constantly react to state changes by re-rendering parts of your application
- Structure your application with (reusable) components that contain their behaviour and expose methods so that they can be interacted with
Installation - setup
npm install --save domstatejsxIf you are running a vite project you have the following options to enable JSX:
Use the vite plugin:
// vite.config.js import domstatejsxPlugin from 'domstatejsx/vite-plugin'; export default { plugins: [domstatejsxPlugin()], // ... };Setup the
esbuildoption:// vite.config.js export default { esbuild: { jsx: 'automatic', jsxImportSource: 'domstatejsx', }, // ... };Include this in every .jsx file you want
domstatejsxto take over handling of JSX:import { createElement, Fragment } from 'domstatejsx'; /** @jsx createElement */ /** @jsxFragment Fragment */
Quickstart
Your first application can look like this:
document.body.append(<h1>hello world</h1>);JSX expressions return native DOM elements. The above is roughly equivalent to:
const element = document.createElement('h1');
element.textContent = 'hello world';
document.body.append(element);JSX expressions can also render components, which are simply functions that return DOM elements:
function Counter() {
return (
<>
<div>
<button>Click me</button>
</div>
<div>
Count: <span>0</span>
</div>
</>
);
}
document.body.append(<Counter />);Note:
In React, there is a difference between
<Counter />andCounter(). As a function,Countermay return JSX but React makes a note that the first invocation mounts a component to the DOM and maintains its lifecycle (with hooks etc). In domstatejsx, there is no difference; you could have written the above like this and it would have made no difference:document.body.append(Counter());
Managing state
Where will we put our state variables? The answer is: the span element itself!
function Counter() {
const countSpan = <span>0</span>;
function handleClick() {
const prevValue = parseInt(countSpan.textContent);
countSpan.textContent = `${prevValue + 1}`;
}
return (
<>
<div>
<button onClick={handleClick}>Click me</button>
</div>
<div>Count: {countSpan}</div>
</>
);
}
document.body.append(<Counter />);If we want to keep a part of the returned DOM to a variable, like we did here,
we can use refs. They work similarly to React; the DOM element is assigned to
the .current attribute of the ref.
function Counter() {
const countSpanRef = {};
function handleClick() {
const prevValue = parseInt(countSpanRef.current.textContent);
countSpanRef.current.textContent = `${prevValue + 1}`;
}
return (
<>
<div>
<button onClick={handleClick}>Click me</button>
</div>
<div>
Count: <span ref={countSpanRef}>0</span>
</div>
</>
);
}
document.body.append(<Counter />);When a
refprop is encountered in JSX, the produced DOM element will be added as thecurrentproperty of the "ref". The previous snippet is roughly equivalent to:function Counter() { function handleClick() { // ... } const countSpanRef = { current: <span>0</span> }; return ( <> <div> <button onClick={handleClick}>Click me</button> </div> <div>Count: {countSpanRef.current}</div> </> ); }
Since you will want to use refs a lot, there are helper function to create them:
useRefs
const [ref1, ref2, ref3] = useRefs();
// Rougly equivalent to
const [ref1, ref2, ref3] = [{}, {}, {}];(refs are simply empty objects, useRefs returns an endless list of refs)
useRefProxy
useRefProxy creates refs lazily by using
Proxy objects.
function Counter() {
- const countSpanRef = {};
+ const refs = useRefProxy();
function handleClick() {
- const prevValue = parseInt(countSpanRef.current.textContent);
+ const prevValue = parseInt(refs.countSpan.current.textContent);
- countSpanRef.current.textContent = `$(prevValue + 1)`;
+ refs.countSpan.current.textContent = `$(prevValue + 1)`;
}
return (
<>
<div>
<button onClick={handleClick}>Click me</button>
</div>
<div>
- Count: <span ref={countSpanRef}>0</span>
+ Count: <span ref={refs.countSpan}>0</span>
</div>
</>
);
}Changing the DOM through refs
Because we will be using refs to modify the DOM a lot, here is a helper "hook":
function Counter() {
const refs = useRefProxy();
+ const [getCount, setCount] = useIntContent(refs.countSpan);
function handleClick() {
- const prevValue = parseInt(refs.countSpan.current.textContent);
+ const prevValue = getCount();
- refs.countSpan.current.textContent = `$(prevValue + 1)`;
+ setCount(prevValue + 1);
}
return (
// ...
);
}Or simpler:
function Counter() {
const refs = useRefProxy();
- const [getCount, setCount] = useIntContent(refs.countSpan);
+ const [, setCount] = useIntContent(refs.countSpan);
function handleClick() {
- const prevValue = getCount();
- setCount(prevValue + 1)
+ setCount((prev) => prev + 1);
}
return (
// ...
);
}We have a lot of hooks like useIntContent that simplify inspecting and
modifying DOM elements; feel free to look them up in the
API reference section.
How components communicate with each other
Because component functions return simple DOM elements, it's hard to think of them as "alive", as if we are able to interact with them after they are created. We have several options in order to achieve this, including ref contexts, context lookup and callbacks. We will talk about two scenarios:
- A child component wants to invoke an action on a parent component, ie upwards
- A parent component wants to invoke an action on a child component, ie downwards
Upwards, with callbacks
You can interact with parent components with callbacks. Let's consider this:
export default function App() {
return (
<>
<span>0</span>
<ButtonContainer />
</>
);
}
function ButtonContainer() {
return <button>Click me</button>;
}Lets add a function that increments the counter in the parent component:
export default function App() {
+ const refs = useRefProxy();
+ const [, setCount] = useIntContent(refs.span);
+
+ function increment() {
+ setCount((prev) => prev + 1);
+ }
return (
<>
- <span>0</span>
+ <span ref={refs.span}>0</span>
<ButtonContainer />
</>
);
}
function ButtonContainer() {
return (
<button>Click me</button>
);
}And pass it as a prop to the child component
export default function App() {
const refs = useRefProxy();
const [, setCount] = useIntContent(refs.span);
function increment() {
setCount((prev) => prev + 1);
}
return (
<>
<span ref={refs.span}>0</span>
- <ButtonContainer />
+ <ButtonContainer onClick={increment} />
</>
);
}
-function ButtonContainer() {
+function ButtonContainer({ onClick }) {
return (
- <button>Click me</button>
+ <button onClick={onClick}>Click me</button>
);
}Upwards, with context lookup
Alternatively you can find a parent component's context in order to interact with it. Lets start by having the parent component expose its context.
export default function App() {
const refs = useRefProxy();
const [, setCount] = useIntContent(refs.span);
function increment() {
setCount((prev) => prev + 1);
}
return (
- <>
+ <App.Context.Provider value={{ increment }}>
<span ref={refs.span}>0</span>
<ButtonContainer />
- </>
+ </App.Context.Provider>
);
}
+App.Context = createContext();
function ButtonContainer() {
return (
<button>Click me</button>
);
}Then, you can find the context from the child component:
export default function App() {
// ...
App.Context = createContext();
function ButtonContainer() {
+ const refs = useRefProxy();
+
+ function handleClick() {
+ const { increment } = useContext(refs.head.current, App.Context);
+ increment();
+ }
return (
- <button>Click me</button>
+ <button onClick={handleClick} ref={refs.head}>Click me</button>
);
}The context gets attached to the DOM. This is why useContext must use a DOM
element as the first argument to use as the starting point for its search. Then
it goes "up" until it finds a node with an App.Context context associated with
it and returns its value.
Downwards, accessing context through a ref
Lets reverse our example:
export default function App() {
return (
<>
<Counter />
<button>Click me</button>
</>
);
}
function Counter() {
return <span>0</span>;
}We will start by having the child component expose its functionality through context:
export default function App() {
return (
<>
<Counter/>
<button>Click me</button>
</>
);
}
function Counter() {
+ const refs = useRefProxy();
+ const [, setSpan] = useIntContent(refs.span);
+ function increment() {
+ setSpan((prev) => prev + 1);
+ }
return (
+ <Counter.Context.Provider value={{ increment }}>
- <span>0</span>
+ <span ref={refs.span}>0</span>
+ </Counter.Context.Provider>
);
}
+Counter.Context = createContext();Then, the parent component can attach a ref to the child component and access its context:
export default function App() {
+ const refs = useRefProxy();
+ function handleClick() {
+ refs.counter.context.increment()
+ }
return (
<>
- <Counter/>
+ <Counter ref={refs.counter}/>
- <button>Click me</button>
+ <button onClick={handleClick}>Click me</button>
</>
);
}
function Counter() {
// ...
}
Counter.Context = createContext();Downwards, with context lookup
You can also search for context downwards. This is especially helpful if you want to affect many child components at the same time:
export default function App() {
return (
<>
<Counter />
<Counter />
<Counter />
// ...
<button>Click me</button>
</>
);
}
function Counter() {
// ...
}
Counter.Context = createContext(); export default function App() {
+ const refs = useRefProxy();
+ function handleClick() {
+ useContext(refs.head.current, Counter.Counter, { direction: 'down' }).forEach(
+ ({ increment }) => increment(),
+ );
+ }
return (
- <>
+ <div ref={refs.head}>
<Counter/>
<Counter/>
<Counter/>
// ...
- <button>Click me</button>
+ <button onClick={handleClick}>Click me</button>
- </>
+ </div>
);
}
function Counter() {
// ...
}
Counter.Context = createContext();useContext with the { direction: 'down' } option searches the DOM under the
starting node and returns a list of found contexts.
Other utilities
Some small utilities have been developed that take inspiraction from popular React libraries that facilitate aspects of frontend development. They were implemented mainly as proofs-of-concept for the functionality they provide:
Queries
useQuery and useMutation can be used to manage interacting with remote APIs.
Their design has been inspired by the
react-query library.
useQuery accepts the following properties:
onStart: function that runs when the query beginsqueryFn: an async function that returns the remote dataonSuccess: function that runs after a successful fetching; it receives the fetched dataonError: function that runs after a failed fetching; it receives the error objectonEnd: function that runs when the query ends, regardless of whether the fetching was successful or notenabled: boolean (default true), determines if the query will run once the moment it is defined
useQuery returns a query object with a refetch method you can use to trigger
a fetch operation. The arguments to refetch will be passed on to queryFn.
Sample usage:
function App() {
const refs = useRefProxy();
const [, setIsLoading] = usePropertyBoolean(refs.button, 'disabled', true, false);
const [, setParagraph] = useTextContent(refs.paragraph);
const { refetch } = useQuery({
queryFn: async () => {
const response = await fetch(...);
return await response.json();
},
onStart: () => {
setParagraph('');
setIsLoading(true);
},
onEnd: () => setIsLoading(false),
onSuccess: ({ message }) => setParagraph(message),
onError: () => setParagraph('Something went wrong'),
});
return (
<>
<p ref={refs.paragraph} />
<button onClick={refetch} ref={refs.button}>Refetch</button>
</>
);
}useMutation accepts the following properties:
onStart: function that runs when the mutation beginsmutationFn: an async function that performs the mutation to the remote APIonSuccess: function that runs after a successful mutation; it receives the remote server's responseonError: function that runs after a failed mutation; it receives the error objectonEnd: function that runs when the mutation ends, regardless of whether the mutation was successful or not
useMutation returns a mutation object with a mutate method you can use to
trigger a mutation. The arguments to mutate will be passed on to mutationFn.
Sample usage:
function App() {
const [input, button, paragraph] = useRefs();
const [getInput, setInput] = useTextInput(input);
const [, setIsLoading] = usePropertyBoolean(button, 'disabled', true, false);
const [, setParagraph] = useTextContent(paragraph);
const { mutate } = useMutation({
mutationFn: () => fetch(...),
onStart: () => setIsLoading(true),
onEnd: () => setIsLoading(false),
onSuccess: () => setParagraph('Saved'),
onError: () => setParagraph('Something went wrong'),
});
async function handleSubmit(event) {
event.preventDefault();
await mutate(getInput());
setInput('');
}
return (
<>
<form onSubmit={handleSubmit}>
<input ref={input} />
<button ref={button}>Save</button>
</form>
<p ref={paragraph} />
</>
);
}Forms
The useForm hook is heavily inspired by
react-hook-form. The idea is that you create a
form object before rendering and then insert its register method into inputs
you want to control with the form. You also get:
- a
registerFormfunction to insert into the<form>element to intercept its submission - a
registerErrorfunction to insert into DOM elements you want validation errors to appear in resetwhich you can invoke to reset all inputs to their default value
Here it is in action:
export default function App() {
const [successMsg] = useRefs();
const [, setSuccessMsg] = useTextContent(successMsg);
const {
handleSubmit,
getData,
register,
registerError,
registerButton,
reset,
} = useForm();
return (
<>
<form
onSubmit={handleSubmit(() => setSuccessMsg(JSON.stringify(getData())))}
>
<p>
Full name:{' '}
<input {...register('full_name', { required: true })} autoFocus />
</p>
<p style={{ color: 'red' }} {...registerError('full_name')} />
<p>
Username:
<input
{...register('username', {
required: true,
validate: (value) => /\s/.test(value) && 'Spaces are not allowed',
})}
/>
</p>
<p style={{ color: 'red' }} {...registerError('username')} />
<p>
Password:
<input
type="password"
{...register('password', { required: true })}
/>
</p>
<p style={{ color: 'red' }} {...registerError('password')} />
<p>
Email:
<input
type="email"
{...register('email', {
required: true,
validate: (value) =>
!/@/.test(value) && 'This is not a valid email address',
})}
/>
</p>
<ul style={{ color: 'red' }} {...registerError('email')} />
<p>
<button {...registerButton()}>Save</button>
<button type="button" onClick={() => reset()}>
Reset
</button>
</p>
</form>
<pre ref={successMsg} />
</>
);
}Routing
The Route and Link components is inspired by
react-router. Each Route accepts a path property
and either an element function or a function inserted as children. It will
then render this element (or children) if the browser's path matches the
component's path. If the path has a parameter (eg /pages/:page), the value of
that parameter will be used as the props of the component.
Link accepts a to property and renders a button that will navigate you to
that (absolute) path.
If a path is not found, the closest parent Route with a NotFound property
will render the NotFound property.
Here it is in action:
export default function App() {
return (
<Route path="" NotFound={() => <h1>Page not found</h1>}>
{() => (
<>
<div>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/pages">Pages</Link>
</div>
<Route path="/" end>
{() => <h1>This is home</h1>}
</Route>
<Route path="/about" end>
{() => <h1>This is about</h1>}
</Route>
<Route path="/pages">
{() => (
<>
<h1>This is pages</h1>
<div>
<Link to="/pages/1">1</Link>
<Link to="/pages/2">2</Link>
<Link to="/pages/3">3</Link>
</div>
<Route path="/:page" end>
{({ page }) => <h1>This is page {page}</h1>}
</Route>
</>
)}
</Route>
</>
)}
</Route>
);
}Controlled Inputs
Writing controlled inputs is a bit harder than doing so in React. There are two components to this.
Controlling native inputs
function App() {
const refs = useRefProxy();
const [get, set] = useTextInput(refs.input);
function handleChange(event) { /* ... */ }
return (
<input ref={refs.input} onChange={handleChange} value="defaultValue" />
);
}You have several handles on this element:
- You can set its default value (the
valueprop) - You can get its current value with
get - You can change its current value with
set - You can respond to changes made by the user with
onChange
Controlling custom components
The goal is to replicate these handles for custom components. Let's do it step by step: Let's start by writing our own input component that just wraps a native input:
function MyInput({ value, onChange }) {
return (
<input value={value} onChange={onChange} />
);
}Let's now expose its get and set functions with context:
function MyInput({ value, onChange }) {
+ const refs = useRefProxy();
+ const [get, set] = useTextInput(refs.input);
return (
+ <MyInput.Context.Provider value={{ get, set }}>
<input value={value} onChange={onChange} />
+ </MyInput.Context.Provider>
);
}
+MyInput.Context = createContext();And finally, let's rewrite our App from before with our new custom input:
function App() {
const refs = useRefProxy();
- const [get, set] = useTextInput(refs.input);
+ const [get, set] = useControlledInput(refs.input);
function handleChange(event) { /* ... */ }
return (
- <input ref={refs.input} onChange={handleChange} value="defaultValue" />
+ <MyInput ref={refs.input} onChange={handleChange} value="defaultValue" />
);
}
useControlledInputworks with any component that exposesgetandsetfunctions with its context.
You can of course use any component you want, not just simple wrappers of native inputs. Here is a custom radio component:
function Radio({ value, onChange, options }) {
const refs = useRefProxy();
const name = uuid4(); // Used to define the radio group
function get() {
const checkedLabels = [...refs.head.current.childNodes]
.filter((label) => label.childNodes[0].checked);
if (checkedLabels.length) {
return checkedLabels[0].childNodes[1].textContent;
}
return null;
}
function set(value) {
[...refs.head.current.childNodes]
.filter((label) => label.childNodes[1].textContent === value)
.forEach((label) => label.childNodes[0].checked = true);
}
return (
<Radio.Context.Provider ref={refs.head} value={{ get, set }}>
{options.map((option) => (
<label>
<input
type="radio"
onChange={() => onChange(option)}
checked={option === value}
name={name} // Radio group
/>
{option}
</label>
))}
</Radio.Context.Provider>
);
}
Radio.Context = createContext();Testing
You can test domstatejsx components using vitest and @testing-library/dom.
Install the packages:
npm install --save-dev @testing-library/dom jsdom vitestCreate a
vitest.config.tsfile:import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'jsdom', }, esbuild: { jsx: 'automatic', jsxImportSource: 'domstatejsx', }, });Or add the test configuration to your existing
vite.config.js:export default { // ... test: { globals: true, environment: 'jsdom', }, };Create your test files ending in
.test.jsxor.test.tsx.Run the tests with
npx vitestor add a test script to
package.json{ ... "scripts": { "test": "vitest run" } }and run with
npm test
Lets pretend we want to test this simple component:
// counter.jsx
import { useIntContent, useRefs } from 'domstatejsx';
export default function Counter() {
const [spanRef] = useRefs();
const [, setCount] = useIntContent(spanRef);
return (
<>
<div>
<button onClick={() => setCount((p) => p + 1)}>ClickMe</button>
</div>
<div>
<span ref={spanRef}>0</span>
</div>
</>
);
}We can do it like this:
import { fireEvent, screen } from '@testing-library/dom';
import { afterEach, expect } from 'vitest';
import Counter from './counter';
afterEach(() => {
document.body.replaceChildren();
});
test('Renders a counter', () => {
document.body.append(<Counter />);
expect(screen.queryByText('ClickMe')).not.toBeNull();
expect(screen.queryByText('0')).not.toBeNull();
});
test('Clicking increments counter', () => {
document.body.append(<Counter />);
fireEvent(screen.getByText('ClickMe'), new MouseEvent('click'));
expect(screen.queryByText('0')).toBeNull();
expect(screen.getByText('1')).not.toBeNull();
});
test('Clicking twice increments counter twice', () => {
document.body.append(<Counter />);
[...Array(2)].map(() => {
fireEvent(screen.getByText('ClickMe'), new MouseEvent('click'));
});
expect(screen.queryByText('0')).toBeNull();
expect(screen.queryByText('1')).toBeNull();
expect(screen.queryByText('2')).not.toBeNull();
});API reference
"Hooks"
useRefsis a function that returns an endless list of "empty refs", which are simply empty javascript objects. The following are roughly equivalent:const [a, b, c] = [{}, {}, {}];const [a, b, c] = useRefs();The
refproperty in JSX will instruct the renderer to assign the resulting DOM element to the.currentfield of the ref. So, the following are equivalent:function Timer() { const [numSpan] = useRefs(); const span = <span>0</span>; numSpan.current = span; return <h1>{span} seconds since start</h1>; }function Timer() { const [numSpan] = useRefs(); return ( <h1> <span ref={numSpan}>0</span> seconds since start </h1> ); }useRefProxycreates refs lazily using Proxy objects. Instead of declaring individual refs, you get an object that creates refs on-demand when you access properties:function App() { const refs = useRefProxy(); return ( <> <input ref={refs.username} /> <input ref={refs.password} /> <button ref={refs.submitBtn}>Submit</button> </> ); }This is equivalent to
const refs = { username: {}, password: {}, submitBtn: {} }but more concise when you have many refs.useIntContentreceives a ref as an argument and returns 2 functions: a getter and a setter. The getter returns the content of the element in integer format and the setter receives a number and sets it as the content of the element. The setter can also receive a function in order to perform incremental changes.
Here is the full list of hooks:
useTextContent: Inspect/modify the text content of an elementfunction App() { const [textHeader] = useRefs(); const [, setText] = useTextContent(textHeader); setInterval(() => { setText((prev) => prev + ' and on'); }, 1000); return <h1 ref={textHeader}>Time goes on</h1>; }useCheckbox: Inspect/modify the "checked" status of a checkboxfunction App() { const [checkbox] = useRefs(); // A setter is also returned but we don't use it const [isChecked] = useCheckbox(); function handleClick() { alert(`The checkbox is ${isChecked() ? '' : 'not'} checked`); } return ( <> <div> <input type="checkbox" ref={checkbox} /> </div> <div> <button onClick={handleClick}>ClickMe</button> </div> </> ); }useTextInput: Inspect/modify the value of a text inputfunction App() { const [textInput] = useRefs(); // A setter is also returned but we don't use it const [getText] = useTextInput(textInput); function handleClick() { alert(`Hello ${getText()}`); } return ( <> <div> <input value="world" ref={textInput} /> </div> <div> <button onClick={handleClick}>ClickMe</button> </div> </> ); }useNumberInput: Inspect/modify the value of a number input as an integerfunction App() { const [numberInput] = useRefs(); const [getNumber, setNumber] = useNumberInput(numberInput); function handleIncrement() { setNumber((prev) => prev + 1); } return ( <> <div> <input type="number" value="0" ref={numberInput} /> </div> <div> <button onClick={handleIncrement}>Increment</button> </div> </> ); }useErrorMessage: This works likeuseTextContent, but will also make sure the whole element becomes hidden when the setter's argument is falsyfunction App() { const [textInput, errorDiv] = useRefs(); // A setter is also returned but we don't use it const [getText] = useTextInput(textInput); const [, setError] = useErrorMessage(errorDiv); function handleClick() { if (getText().indexOf('@') === -1) { setError('Not a valid email address'); } else { setError(null); } } return ( <> <div> <input ref={textInput} /> </div> <div style={{ display: 'none' }} ref={errorDiv} /> </> ); }useStyleBoolean: This toggles a style property between two values. The signature of the hook is:useStyleBoolean(ref, property, onValue, offValue). The getter tells us if the styleproperty's value matches theonValueand the getter receives a boolean and sets theproperty's value toonValueoroffValue.function App() { const [span] = useRefs(); const [, setLineThrough] = useStyleBoolean( span, 'text-decoration', 'line-through', null, ); function handleCheck(event) { setLineThrough(event.target.checked); } return ( <> <span ref={span}>Hello world</span> <input type="checkbox" onChange={handleCheck} /> </> ); }usePropertyBoolean: This works likeuseStyleBooleanbut for generic HTML properties:function App() { const [button] = useRefs(); const [, setLoading] = usePropertyBoolean(button, 'disabled', true, false); async function handleClick() { setLoading(true); await fetch(...); setLoading(false); } return ( <button onClick={handleClick} ref={button}>Download stuff</button> ); }useClassBoolean: This works likeuseStyleBooleanbut for HTML classes (for this example assume we are using tailwind CSS):function App() { const [span] = useRefs(); const [, setLineThrough] = useClassBoolean(span, 'line-through', null); function handleCheck(event) { setLineThrough(event.target.checked); } return ( <> <span ref={span}>Hello world</span> <input type="checkbox" onChange={handleCheck} /> </> ); }useList: This accepts a ref and a function. The setter adds new child elements to the element bound to the ref by invoking the function with the arguments to the setter (which also supports variable argument length). The getter returns refs for all the items that have been added by the setter:function App() { const [textInput, todoList] = useRefs(); const [getText, setText] = useTextInput(textInput); const [getTodos, addTodo] = useList(todoList, ({ text }) => ( <li>{text}</li> )); function handleSubmit(event) { event.preventDefault(); if (!getText()) return; addTodo({ text: getText() }); setText(''); } function showSummary() { alert( JSON.stringify(getTodos().map(({ current }) => current.textContent)), ); } return ( <> <form onSubmit={handleSubmit}> <input ref={textInput} /> <button>Add</button> </form> <ul ref={todoList} /> <button onClick={showSummary}>Show summary</button> </> ); }(This will make more sense once we talk about contexts later on)
useLocalStorage: This accepts a localStorage key and returns a getter/setter pair for reading/writing to localStorage. The getter returns the stored string value (ornullif not set), and the setter saves the value to localStorage:function App() { const refs = useRefProxy(); const [getInput, setInput] = combineHooks( useLocalStorage('app-input'), useTextInput(refs.input), ); return ( <input ref={refs.input} onChange={(e) => setInput(e.target.value)} value={getInput() || ''} /> ); }In this example, the input's value is automatically synced with localStorage. When you type in the input, both the input value and localStorage are updated (thanks to
combineHooks). When the page reloads,getInput()reads from localStorage to restore the previous value.combineHooks: This accepts other hooks (or any getter/setter pair) and returns a single getter/setter pair. The combined getter simply returns the return value of the first hook and the combined setter invokes all the setters:function App() { const [checkbox, text] = useRefs(); const [isDone, setIsDone] = combineHooks( useCheckbox(checkbox), useStyleBoolean(text, 'text-decoration', 'line-through', null), ); return ( <> <input type="checkbox" ref={checkbox} /> <span ref={text}>Water the plants</span> </> ); }
Context
createContext: This creates a new context object. Receives a default value.Provider: A component that receives a value and attaches it to the DOM so that it can be found byuseContextuseContext: Receives a DOM element and a context object and scans "upwards" in the DOM to find a parent element that uses this context object to provide a value. Accepts a third 'options' property that has adirectionkey that accepts 3 values:up: The default value, searches upwards to find a DOM element for the contextdown: Searches "below" in the DOM tree to find all elements that have a context specified by the context object; returns a list of contextsside, combined withupContext: Combines the two searches; first it search upward until it finds a parent element that uses theupContextthen, using that element as a starting point, searches downwards to find all contexts that match the context argument
Development playground
This repository includes a playground with example applications.
To run the playground:
git clone https://github.com/kbairak/domstatejsx
cd domstatejsx
npm install
npm run devThe playground contains several demo applications under ./src/playground/. To
switch between them, change the first line in ./src/playground/main.tsx:
-import App from './todos';
+import App from './accordion';Available demos: accordion, todos, form, pagination, pager, home,
etc.
Here is a blog post where I explain how this works.
TODOs
General
- [ ] Documentation
- [x] Build options to extract the library to
dist - [x] Upload to NPM
- [x] Add types
- [x] Create vite plugin for easy use
- [ ] Look into possible memory leaks
- [x] Instructions on how to write tests
Specific features
General:
- [ ] Find ways to keep the refs definition and jsx closer for easier reading
Forms:
- [x] Forms should be able to work with
<select>elements - [x] Forms should be able to work with custom input components
Routing:
- [ ] Routing with
<Outlet/>s - [ ] Data router
- [x] Make
<Link>s aware of whether they are selected - [x] There is a bug somewhere with path matching (I don't remember what exactly)
Context:
- [x] Test with different contents of
<Provider>s - [x] See if we can avoid extra
<div>s with fragments
