Hooks
The point: reuse stateful logic
Sometimes the interesting part of a component isn't its markup — it's its behaviour: fetching data and tracking whether it has arrived, following the window size, running a countdown. When several components need the same behaviour, you don't want to copy it into each one. A hook is that behaviour pulled out into one reusable place: state, lifecycle and subscriptions together, with no element of its own.
import { Hook, state } from "@ramonda/core";
export class Toggle extends Hook {
@state open = false;
toggle() {
this.open = !this.open;
}
}
Any component uses it with this.use():
export class Menu extends Component {
menu = this.use(Toggle);
render() {
return (
<div>
<button onclick={this.menu.toggle}>{this.menu.open ? "Close" : "Open"}</button>
{this.menu.open ? <ul>…</ul> : null}
</div>
);
}
}
A Dialog, a Dropdown and an Accordion can all this.use(Toggle) — the
open/close logic lives in one class instead of being copied three times. That reuse
is the whole reason hooks exist.
Why not just a component?
A component is always exactly one element, so "state and lifecycle but no markup" can't be a component — it would still be an element. That is the gap a hook fills. (If a wrapper element wouldn't bother you, an ordinary component works too — its default element takes up no space. Reach for a hook when you want no element, or when the goal is to share the behaviour itself.)
Synchronising with the owner
A hook often needs a value from the component using it — the id to fetch, the size to paginate by. You pass it as props. Pass a callback, and it re-runs whenever a signal it reads has moved, so the hook stays in step with the owner's data — and stays put when the owner re-renders for a reason that has nothing to do with it.
export class Resource<T> extends Hook<{ url: string }> {
@state data: T | null = null;
@created
first() {
void this.load(this.props.url);
}
// Runs when `url` changes, before the render — so the "loading" state is on screen in
// the same pass rather than one frame later.
@watchProp((props) => props.url)
reload([next]: [string]) {
void this.load(next);
}
private async load(url: string) {
this.data = null;
const response = await fetch(url);
// `@destroyed` is where a real one would cancel; RMD008 reports a write after unmount.
this.data = await response.json();
}
}
export class UserCard extends Component<{ id: string }> {
// The callback reads `id`, so it re-runs when `id` moves and `url` follows it.
user = this.use(Resource, (self: UserCard) => ({ url: `/api/users/${self.props.id}` }));
render() {
return this.user.data ? <p>{this.user.data.name}</p> : <p>Loading…</p>;
}
}
When the parent passes a new id, the callback produces a new url, and the
@watchProp on it refetches — with no wiring on your part.
Props are tracked per key (exactly like props), so the hook
reacts to the prop that changed and not to the others. Authoring them in detail is
writing a hook.
A hook can use another hook
Hooks compose: a hook can this.use() another, building bigger behaviour out of
smaller pieces.
export class UserProfile extends Hook<{ id: string }> {
private user = this.use(Resource, (self: UserProfile) => ({ url: `/api/users/${self.props.id}` }));
private posts = this.use(Resource, (self: UserProfile) => ({ url: `/api/users/${self.props.id}/posts` }));
get ready() {
return this.user.data !== null && this.posts.data !== null;
}
}
The whole chain shares one owner and updates together — when the owner re-renders, each hook's props are re-evaluated in turn, down through the nested ones.
Naming one for devtools
Two of the same hook in one component are two nodes with one name, because a class is shared by
every instance — this.constructor.name is Resource for both of the ones above. A third
argument to use() says which is which:
private user = this.use(Resource, (self: UserCard) => ({ url: `/api/users/${self.id}` }), {
label: "user",
});
Devtools then calls it Resource (user): the class says what it is, the label says which one. A hook
with no props takes the placeholder — this.use(Poll, undefined, { label: "prices" }).
That argument is metadata about the hook, and it is deliberately not a prop. A hook's props belong to whoever wrote the hook, so a framework word reserved among them would collide with a real one eventually. The hook never sees this argument, and a production build stores none of it.
When things fire
- A hook is created the moment
this.use()runs — while the owner itself is being built, before the owner's own@created. Hooks are built inthis.use()order. - Its lifecycle is part of the owner's, not a separate pass. A hook has no
element, so there's no separate mount for it: its
@createdruns as the owner is built, its@mountedonce the owner's DOM is on the page, its@destroyedwhen the owner is removed. You can watch the exact interleaving in the lifecycle demo. - On every re-render of the owner, the hook tree is walked in
use()order and the new values flow into each hook, cascading down through any nested hooks. A hook's props callback is only called if a signal it reads has moved; the walk itself never skips, because a nested hook can depend on state its parent's bag says nothing about.
The one cost to know
A hook shares its owner's re-rendering: when a hook's state changes, the owner re-renders — the hook has no smaller boundary of its own. That is the one thing a child component gives you that a hook doesn't. If a hook's state changes very often and the owner is expensive to draw, a child component may be the better shape.
A hook can return markup
A hook has no element, but it can still produce markup for the owner to place — useful
when a group of elements needs shared state but no wrapper (inside a <tr> or
<select>, where an extra element is illegal):
class Toolbar extends Hook<{ actions: Action[] }> {
@state busy = false;
// Cached by its argument, per instance — so each button keeps one handler across
// renders instead of getting a fresh closure (which RMD020 reports).
@memoized
runner(id: string) {
return () => this.run(id);
}
renderAction(action: Action) {
return <button disabled={this.busy} onclick={this.runner(action.id)}>{action.label}</button>;
}
buttons() {
return list(this.props.actions, this.renderAction);
}
}
// in the owner:
private toolbar = this.use(Toolbar, () => ({ actions: this.actions }));
render() {
return <div>{this.toolbar.buttons()}</div>;
}
A hook can hand back a list() like any other markup — the descriptor is
created here and the rows are built where it lands, in the owner's children. So the
buttons keep their identity through a reorder, which a .map() in the same place would
not.
Next
- Writing a hook — props in depth, and keeping them reactive.