React-like Hooks tailored for Web Components, inspired by https://github.com/matthewp/haunted
The core idea is similar to React's hooks so you will feel at home if you're already familiar with them.
The library has been made with three objectives in mind:
- provide hooks that improve the developer experience of building web components
- remain as small as possible without sacrificing code readability
- try to address problems inherent to react-like hooks, notably dependency tracking hell
This library being published on npm, you can:
npm install wchooks
if you are using a bundler- import the module from a CDN, e.g.
import { useState } from "https://unpkg.com/wchooks"
In your project you will also have to import a library like lit-html
because wchooks
is designed to offload the actual DOM rendering to these external libraries, as they already do a perfect job.
A counter with a button to increment its value:
import { html, render } from "lit-html";
import { withHooks, useState, useEffect } from "wchooks";
const Counter = () => {
const [counter, setCounter] = useState(0);
useEffect((counter) => {
console.log("counter rendered:", counter);
}, [counter]);
return html`
<div>
<span>counter: ${counter}</span>
<button @click=${() => setCounter(counter + 1)}>Increment</button>
</div>
`;
};
customElements.define("my-counter", withHooks(Counter, render));
The withHooks
factory is the function used to build a custom HTMLElement
that can work with hooks.
Its first argument should be a renderer function that runs hooks and returns a value that can be rendered with the render
function passed as second argument.
This render
function applies the templates returned by your renderers to the DOM, this is e.g. import { render } from "lit-html"
.
The class returned by the withHooks
factory is an extension of HTMLElement
and can be used when calling customElements.define()
.
function withHooks<T>(
renderer: () => T,
render: (templateResult: T, root: ParentNode) => void,
options?: HookedOptions
): CustomElementConstructor;
You also have a few other options to customize the behavior of your component:
observedAttributes
: List of attributes that should trigger a rerender in your component when they changeattachRoot
: Pick a rendering root different than the default openshadowRoot
Element
: Specify another class than HTMLElement that your component should extend
interface HookedOptions {
attachRoot?: (element: HTMLElement) => Element;
Element?: typeof HTMLElement;
observedAttributes?: string[];
}
Create a reference to an object that will remain at the same address for the whole life of the component. The value is then accessible through ref.value
.
interface Ref<T> {
value: T;
}
function useRef<T>(initialValue: T): Ref<T>;
Create a dynamic state that triggers a rerender when it is changed through the returned setter.
Successive synchronous calls to any setters will be batched and trigger only one update. This is also true for the setters of useReducer
.
interface Setter<T> {
(value: T): void;
(value: (oldValue: T) => T): void;
}
function useState<T>(initialState: T): [T, Setter<T>];
Create a state managed by a reducer that returns the current value and a dispatch function to update it.
When called, the dispatch function will run the reducer with the current state and the argument given to dispatch. The value returned by the reducer will be the new state.
The passed initialState
can be a function. In that case, it will receive 2 arguments (dispatch, getState)
. This allows you to define methods inside your state that can read and write this same state.
interface Reducer<T, A> {
(state: T, action: A): T;
}
interface Dispatch<A> {
(action: A): void;
}
interface CreateState<T, A> {
(dispatch: Dispatch<A>, getState: () => T) => T
}
function useReducer<T, A>(createState: T | CreateState<T>, reducer: Reducer<T, A>): [T, Dispatch<A>];
Creates a wrapper around an async function that allows tracking the evolution of the async operation while preventing racing conditions between consecutive calls.
interface Async<F extends AsyncFn> {
loading: boolean;
value: PromiseType<F> | undefined; // PromiseType = type of the promise returned by the async function
error: Error | undefined;
call: F;
}
interface AsyncFn {
(...args: any[]): Promise<any>;
}
function useAsync<F extends AsyncFn>(asyncFn: F): Async<F>;
Specify a list of default values for the element's attributes and return their current actual value.
For these attributes to trigger an update when they change, they should be added to the component observedAttributes
.
The attributes default values passed as argument will be used to guess the kind of parsing/serialization needed to interact with the attribute in the DOM.
For example, if we have { "my-flag": true }
, the attribute will be shown as "my-flag"=""
in the DOM.
If we have { "my-flag": false }
, the attribute will be removed.
function useAttributes<A extends { [name: string]: any }>(initialAttributes: A): A;
Specify a list of default values for the element's properties and return their current actual value.
The properties are initialized with the given default value. Then, any time they'll be modified, it will trigger an update.
These properties become accessible directly on the DOM element, wether they are values or functions. This allows you to build an API to control your component private state from the outside.
If your property is defined as a function, it will automatically be bound to the withHooks element. That way you can access the component inside the method with this
. Note that this won't work if you define your property as an arrow function, as they cannot be rebound.
function useProperties<P extends { [name: string]: any }>(properties: P): P;
Create event dispatchers for the given events with their default options.
The first argument of the dispatcher is the detail of your custom event, the second allows you to override the previously defined default options.
interface DispatchEvent<T> {
(detail?: T, options?: CustomEventInit<T>): CustomEvent;
}
function useEvents<E extends string>(events: { [event in E]: CustomEventInit<any>; }): { [event in E]: DispatchEvent<any>; }
This library tries to address the dependency tracking issues of React's hooks by offering only two hooks that you should go to whenever you want something to change along with your states.
As opposed to React, the callbacks for these hooks are registered only during the first render, so any following rerender will not affect the values in their closure. This means that you cannot directly use values coming from e.g. useState
or useProperties
inside your callback function as every time it'll be called, these values will remain the same they were the first time the component was rendered, even if the state or property has actually changed in the meantime.
Note: exceptions to this are refs from
useRef
, the setter function ofuseState
and the dispatch function ofuseReducer
, as they all remain exactly the same throughout the life of your component. The same goes for any other static references coming from outside your component.
So if you want to actually make your hooks aware of their surrounding, you can give them a list of dependencies as second argument. Then, when a hook callback will be called, all those deps will be spread as its arguments. This mitigates stale dependency bugs by forcing you to create a clear scope in which your hook should work. It results in a more verbose code than React's, but maybe it's for the best.
Hooks with deps also accept a custom isEqual
function as third argument. It will be used when comparing new deps with old deps to confirm that they have changed.
Only recreate the value when the deps change.
If no deps are specified, the value will be created only once and won't ever change. Otherwise, the array of deps is spread as arguments of the callback function.
interface IsEqual<D extends any[]> {
(deps: D | undefined, oldDeps: D | undefined) => boolean
}
function useMemoize<T, D extends any[]>(createValue: (...deps: D) => T, deps?: D, isEqual?: IsEqual<D>): T;
This hook will run a callback right after an update (modification of state, property, attribute, etc) has been rendered to the DOM.
Every time the deps change, it will be called again. But providing no deps at all will make the hook run the callback on every render.
In order to clear whatever was setup in the side effect, your callback should return a function that takes care of this clean up.
When the useEffect
hook is invoked, the current deps will be spread as the arguments of the callback function.
type EffectCallback<D extends any[]> = (...deps: D) => void | (() => void);
function useEffect<D extends any[]>(
effectCallback: EffectCallback<D>,
deps?: D,
isEqual?: IsEqual<D>
): void;