Hooks — MeoNode UI
@meonode/ui ships three hooks: one for theming, one for managing the portal stack, and one for high-frequency data updates that should bypass React's render cycle.
useTheme
Reads the current theme from context. What you get back depends on which provider is above it.
const { mode, setMode, preference, setPreference, hydrated, modes, theme } = useTheme()
| Field | Description |
|---|---|
mode | The mode now applied, e.g. 'dark'. Narrowed to your own mode names by the MeoTheme augmentation. |
setMode | Applies a mode and stores it as the reader's choice. |
preference | What the reader chose, which is not always what they see: 'system' stays 'system'. |
setPreference | Sets the choice. 'system' hands control back to the operating system. |
hydrated | false on the server and during the first client render, true afterwards. See the warning below. |
modes | The mode names this app declared. |
theme | The resolved token object. |
import { useTheme, Button } from '@meonode/ui' const ThemeToggle = () => { const { mode, setMode } = useTheme() // `mode` is read in the handler, never rendered into markup. return Button('Toggle theme', { onClick: () => setMode(mode === 'dark' ? 'light' : 'dark'), }) }
Do not render markup from mode
The server renders one document for every reader, so it cannot know which mode anyone is in. A component that
renders from mode — a sun/moon icon, a checked state, a dark-only element — produces one thing on the server and
another on the client for every reader who is not on the default. React answers that mismatch by throwing away the
server tree and rendering the whole page on the client, which also clears the attributes the pre-paint script wrote:
the flicker the script exists to prevent comes back, caused by the component trying to display the theme.
Drive those differences from CSS instead, keyed off the attribute:
Span(undefined, { css: { "[data-theme='dark'] &": { transform: 'translateX(20px)' }, }, })
That is not only a way to avoid the mismatch — it is the only way the control is right before React loads, which is the same reason the script exists. Measured on a real page, an ungated consumer shows the wrong mode for roughly 100–135 ms on every cold load.
If you genuinely must render from mode, gate it on hydrated, which is false for the first client render so it
agrees with the server:
const { mode, hydrated } = useTheme() if (!hydrated) return null
Side effects. setMode and setPreference write the reader's choice to localStorage and stamp
data-theme and data-theme-preference on <html>. The provider also re-asserts both whenever it renders, so the
page recovers if anything else clears them.
Throws if used outside a provider.
Migrating from v2
setTheme is gone. There is no theme object to swap: the provider is configured with mode names and a token
object of var() references, and the colours behind a mode live in CSS.
// v2 const { setTheme } = useTheme() setTheme(darkTheme) setTheme(prev => (prev.mode === 'light' ? darkTheme : lightTheme)) // v3 const { mode, setMode } = useTheme() setMode('dark') setMode(mode === 'light' ? 'dark' : 'light')
The updater form has no direct equivalent, and that is the point rather than a gap — there is nothing to derive a new
theme from. A mode is a name, and what it looks like is a [data-theme='…'] block in your stylesheet.
theme is still on the context and still resolves theme.* token paths. It is read-only now.
Removing setTheme also removed a trap. In v2's mode-aware provider it accepted a whole theme object, took only
.mode from it and discarded the palette — silently, because the mode did change, so it looked like it had worked.
usePortal
Imperative interface for opening, updating, and closing portal layers.
const portal = usePortal<T>(autoSyncData?)
| Argument | Description |
|---|---|
autoSyncData (optional) | A value (state, props, anything) that gets pushed to the most recently opened portal whenever it changes. Lets the portal stay in sync with the parent without manual updateData calls. |
| Method | Description |
|---|---|
portal.open(Component, initialData?) | Opens a new portal layer. If initialData is omitted, falls back to autoSyncData. Returns a PortalHandle. |
portal.close() | Closes the most recently opened layer from this hook instance. |
portal.updateData(next) | Manually pushes new data to the most recently opened layer. |
portal.handle | Ref holding the current PortalHandle, or null when no layer from this hook instance is open. |
import { useState } from 'react' import { usePortal, Button } from '@meonode/ui' import { CounterModal } from './CounterModal' const Counter = () => { const [count, setCount] = useState(0) // CounterModal will receive { count, setCount } and re-sync on every change. const portal = usePortal({ count, setCount }) return Button('Open Counter', { onClick: () => portal.open(CounterModal) }) }
Throws if used outside a PortalProvider. See the Portal System guide for the full setup and the PortalLayerProps shape.
useDataChannel
Subscribes to a DataChannel created by createDataChannel. Updates the consumer without re-rendering its parent — useful for high-frequency values like progress, drag offsets, or live counters.
const value = useDataChannel(channel)
| Argument | Description |
|---|---|
channel | A DataChannel<T> instance (or null / undefined). |
Returns the current channel value, or undefined if no channel is provided.
import { Component, createDataChannel, useDataChannel, Div } from '@meonode/ui' const progressChannel = createDataChannel(0) // Wrap the consumer in `Component` so the subscription lives in its own scope const ProgressBar = Component(function ProgressBar() { const progress = useDataChannel(progressChannel) ?? 0 return Div({ width: `${progress}%`, height: 4, backgroundColor: 'theme.primary', }) }) // Update from anywhere — including outside the React tree progressChannel.set(50)
The consumer must be its own component. useDataChannel holds state in whichever component calls it, so a plain
function called inline (Column({ children: [ProgressBar()] })) subscribes in the parent's scope and re-renders the
parent on every push — exactly what the channel exists to avoid. Wrapping in Component (or reaching it through
Node()) keeps the update confined to the consumer.
channel.set(next) takes a value, not an updater function. Use channel.get() to read the current value if you need to
compute the next one, and channel.subscribe(cb) to observe changes outside React — it returns an unsubscribe function.
Related FAQ
Why do I get a "Rendered fewer hooks than expected" error with conditional components?
This happens when hook execution order changes between renders. Keep hook-using components behind stable boundaries (Node() or Component) and avoid calling them conditionally as plain functions.
How do node functions work?
Node functions are composable factories. Use children-first signatures for text/content nodes and props-first signatures for layout/container nodes.
More details: /docs/getting-started/faq
On this page
- Hooks — MeoNode UI