Rules & Patterns — MeoNode UI
A scannable reference for the rules, conventions, and patterns that come up most when building with @meonode/ui. Most items link to a deeper guide for the full story.
Rules
These cause runtime or type errors if you ignore them.
Components passed to Node() must return a ReactElement
For MeoNode functions, that means calling .render() at the top level:
const Bad = () => Div({ children: 'Hi' }) // returns Node instance const Good = () => Div({ children: 'Hi' }).render() // returns ReactElement Node(Bad) // ❌ React error Node(Good) // ✅
JSX components already return ReactElements, so they work as-is.
Don't wrap built-in MeoNode exports in Node()
Div, Button, ThemeProvider, etc. are already factories — call them directly.
ThemeProvider({ modes, defaultMode, tokens }) // ✅ Div({ children: 'Hi' }) // ✅ Node(ThemeProvider, { modes, defaultMode, tokens }) // ❌ double-wrapping Node(Div, { children: 'Hi' }) // ❌
Don't call hook-using components conditionally
Direct calls like cond && MyHookComponent() change the hook order between renders and crash with "Rendered fewer hooks than expected". Wrap with Node() instead:
cond && Node(MyHookComponent, { ... }) // ✅
Full pattern (with inline-function and Component HOC alternatives) → conditional hooks FAQ.
Name the function you pass to Component
react-hooks/rules-of-hooks identifies components by capitalized name. An anonymous arrow handed to Component() is
just an argument, so the rule does not lint its body at all — a conditional hook in there is invisible to ESLint and
only surfaces as a runtime crash. Giving the function a name restores the check:
// ❌ Hook violations inside this body are never reported const Counter = Component(({ show }) => { if (!show) return Text('hidden') const count = useDataChannel(channel) // silently unchecked return Text(`${count}`) }) // ✅ ESLint reports "React Hook ... is called conditionally" const Counter = Component(function Counter({ show }) { if (!show) return Text('hidden') const count = useDataChannel(channel) return Text(`${count}`) })
For the same reason, keep hooks on their own line at the top of the body rather than inline in an argument or template literal. Inline calls are legal — evaluation is unconditional, so hook order stays stable — but they are harder to scan in exactly the place where the linter is not watching.
Conventions
These are stylistic — code works either way, but matching the convention makes diffs cleaner.
Define your own components with node functions, not JSX
MeoNode integrates fine with JSX (Node(JSXComponent, { … })), but the idiomatic style for your own components is the function-call API:
const Card = ({ title }: { title: string }) => Column({ padding: 16, children: [H2(title), Text('…')], }).render()
Only the top-level node calls .render()
Children inside children: [...] are rendered automatically. Calling .render() on every child is valid but noisy:
// ❌ noisy Column({ children: [H1('Title').render(), Text('Sub').render()] }).render() // ✅ clean Column({ children: [H1('Title'), Text('Sub')] }).render()
The exception: pass .render()-ed children when a custom prop expects a raw ReactElement (e.g. an icon prop on a third-party Button):
Button('Settings', { icon: Node(SettingsIcon).render() })
Lists and keys
React matches children by position unless they carry a key. Delete a row from
an unkeyed list and the survivors do not move with their state — each one
inherits the previous row's: its useState, its focus, whatever the user had
typed into it. That is React's rule, and children: items.map(...) inherits it
in full.
// Rows are matched by position. Delete "b" and the row that was "c" // keeps "b"'s state. Column({ children: todos.map(todo => TodoRow({ todo })) }) // Rows are matched by key. Delete "b" and "c" keeps its own. Column({ children: todos.map(todo => TodoRow({ key: todo.id, todo })) })
For
Writing a key on every row is easy to forget and easy to get wrong — the index
is the usual mistake, and it is the one shape that reintroduces the bug it looks
like it fixes. For takes the data instead of the finished rows and keys each
one from it:
import { For } from '@meonode/ui' Column({ children: For(todos, todo => TodoRow({ todo })) })
Rows are identified by object reference, which survives their contents changing: renaming a row keeps its state, where hashing the contents would discard it on every keystroke. Primitives identify themselves by value, and two genuinely identical items fall back to their position — no worse than the unkeyed list they replace.
The callback receives (item, index, key), matching Array.map rather than
inverting it. For applies the key itself; the third argument is there for a row
that wants to reuse it.
Reference identity cannot follow items that are rebuilt between renders — a
fetch that returns fresh objects for the same logical rows, or a .map() that
spreads. Pass an accessor for those:
Column({ children: For(todos, todo => TodoRow({ todo }), todo => todo.id) })
In development, a list whose items are all new on a second render is reported once with that suggestion. On the first render every object is new, which says nothing, so the check waits for a repeat before concluding anything.
Mixing siblings with a list
A heading above its rows is two things in one children: a sibling you wrote
out, and a list. React treats them differently, and how you combine them decides
whether it can.
// Nested. The heading is exempt; the rows are still checked for keys. Column({ children: [ H2('Today'), todos.map(todo => TodoRow({ key: todo.id, todo })), ], })
Keep the list nested rather than spreading it. React grants the exemption per argument position: a nested array arrives as one argument it treats as a list, while the heading arrives as one a person evidently typed. Spreading flattens that distinction away.
// Spread. The whole array arrives as a single list, so the heading is // asked for a key it does not need. Column({ children: [H2('Today'), ...todos.map(todo => TodoRow({ key: todo.id, todo }))] })
That report is not so much wrong as unable to tell the two apart — from the
inside, a flat array is a list, and H2('Today') is a row in it with no key.
Nesting is how you say otherwise.
Requires @meonode/ui 2.2.1 — 2.2.0 renders it but does not typecheck it. Earlier versions could not render a nested array at
all, which is why spreading was the only way to write this.
When MeoNode tells you
A missing key is reported by React itself, and reaching it requires
@meonode/compiler: whether a list was
generated or written out by hand is decidable only from the source, and by the
time Column({ children }) runs, .map() has already produced an ordinary
array indistinguishable from one you typed.
So the report is opt-in, and the bug is not. An uncompiled build has exactly the
same positional-matching behaviour and simply does not tell you about it. For
is a plain runtime helper and needs no compiler, which makes it the fix that
works either way.
Siblings you write out need no key — that is React's rule too, and listing them by hand is what makes them unambiguous. The exception is the flat spread above, where they stop being distinguishable from the list they were flattened into.
With callSiteLocations enabled the report carries the call site, which React's
own cannot: MeoNode builds the whole tree before handing it over, so every
element in it is created at the same .render() call and React names that line
for a list written anywhere in the file.
Each child in a list should have a unique "key" prop.
[MeoNode] A generated list at src/app/page.ts:124:6 has children without a `key`.
Styling Patterns
Div({ // CSS props go straight on root backgroundColor: 'red', padding: 20, borderRadius: 8, // DOM attributes / handlers / non-CSS custom props also at root onClick: handleClick, id: 'my-div', 'aria-label': 'Label', })
When a custom prop name collides with a CSS property but is meant as logic (e.g. height for a chart's render dimensions), nest it under props so the styling engine ignores it:
Node(Chart, { props: { height: 500 }, // forwarded as logic, not styled padding: 20, // styled (container) })
Full styling reference → Styling guide.
Element polymorphism (as)
Render a styled factory as a different intrinsic tag without duplicating styles:
Div({ as: 'a', href: '/home', padding: 16, backgroundColor: 'theme.primary', children: 'Home' })
Custom components are not valid as targets — use Node(MyComponent, …) or that component's factory instead. as is unavailable on no-style tags like Script.
→ Styling guide — Element Polymorphism
Performance Patterns
Pass a dependency array as the second argument to memoize a node.
Div({ children: 'static' }, []) // renders once, never updates Div({ children: `Count: ${c}` }, [c]) // updates only when c changes
The same [deps] argument works on Component-wrapped functions: MyHocComponent(props, deps).
deps is taken literally
The list is handed to useMemo on a fiber of the node's own, so it means what
it says. An empty array freezes the subtree; a node that should follow a value
has to name it.
Div({ children: `Count: ${c}` }, []) // frozen — keeps the first count Div({ children: `Count: ${c}` }, [c]) // follows c
That applies to every value the node reads, including ones arriving through a spread:
Div({ ...props, padding: 8 }, []) // frozen, whatever props does Div({ ...props, padding: 8 }, [props.id]) // follows props.id
Nodes without a dependency array are not memoized and rebuild with their parent, which is the right default for most of a tree.
A different node rebuilds, even on equal deps
deps decides when a memoized node re-reads its values. It does not freeze the
shape of the subtree: swap the node itself for a different one and the new one
renders, whatever the dependency array says.
Div({ children: cond ? Div({ children: 'A' }, []) : Section({ children: 'B' }, []) }) // Flipping `cond` renders the other branch, not the frozen first one.
[] still means freeze, exactly as useMemo([]) does — for the node it was
written on. A swap replaces that node rather than updating it, so there is
nothing left to freeze.
Memoized nodes are identified by React, not by their props
Each memoized subtree renders inside a fiber of its own, so two nodes with identical props in identical positions stay distinct — in two different components, or as two instances of the same one:
const Sidebar = () => Div({ children: [Div({ padding: 8, children: 'A' }, [])] }) const Footer = () => Div({ children: [Div({ padding: 8, children: 'B' }, [])] }) // Renders "A" and "B".
Nothing is derived from your props to make that work, so there is no key to
collide and nothing to disambiguate by hand. This holds for plain function
components and Component alike, with or without
@meonode/compiler.
You still want a key wherever React wants one — a list, or anything that
reorders — so React keeps each item's identity across the reorder. See
Lists and keys, which MeoNode now reports on.
Component Factories
Pick by usage shape:
| Factory | Use case | Example |
|---|---|---|
Node() | One-off usage, especially for JSX components | Node(TextField, { … }) |
createNode() | Reusable factory, props-first | const Field = createNode(Input) |
createChildrenFirstNode() | Reusable factory, children-first | const Btn = createChildrenFirstNode(Button) |
Component() | Encapsulated logic + UI as a real React component | const Card = Component(function Card(props) { … }) |
Comparison & deeper trade-offs → Node vs createNode vs createChildrenFirstNode FAQ.
Hot Module Replacement
For Vite, sub-components hot-reload reliably when you:
- Use
.tsxfile extensions - Wrap them with
Node()in the parent - Return a
ReactElement(call.render())
Next.js HMR detects MeoNode components without all three, but following the pattern is still the safe default.
Full discussion → HMR sub-components FAQ.
Common Errors
| Symptom | Cause | Fix |
|---|---|---|
| "Rendered fewer hooks than expected" | Hook-using component called directly inside a conditional | Wrap with Node() |
| Style prop ignored on a custom component | CSS props are compiled to an Emotion class and handed over as className, which the component never applies | Apply the incoming className to the component's root element |
Node(MyComponent) throws / blank render | Component returned a Node instance instead of a ReactElement | Add .render() to the top-level node |
| Custom prop appears in DOM / breaks layout | Prop name matches a CSS property and got styled | Move it under props: { yourProp } |
as: MyComponent type error | as only accepts intrinsic HTML tags | Use Node(MyComponent, …) or a dedicated factory |
| Need link semantics on a styled block | Using createNode('div') without href | Div({ as: 'a', href: '…', … }) or createNode('a', …) |
Related FAQ
Why do I get a "Rendered fewer hooks than expected" error with conditional components?
The error happens when hook call order changes between renders. Keep hook-using units behind stable component boundaries by using Node() or Component.
What's the difference between Node(), createNode(), and createChildrenFirstNode()?
Node() is ideal for one-off wrapping, createNode() is for props-first reusable factories, and createChildrenFirstNode() is for reusable children-first factories.
How do node functions work?
Node functions are typed factories that compose UI trees directly in TypeScript and can be memoized with dependency arrays when needed.
What does MeoNode warn about, and how do I enable debug logging?
Theme-token, theme-function and list diagnostics are already on in any development build. setDebugMode(true), called at module scope in your entry file before anything renders, adds malformed compiled markers and swallowed function-child errors, and forces the rest on in a production build.
More details: /docs/getting-started/faq
Next Steps
- Framework Integration — Next.js and Vite setup, including SSR and the build-time compiler
- FAQ — Common patterns and edge cases
- Release Notes — Changelog and upgrade guides
On this page
- Rules & Patterns — MeoNode UI