• Getting Started
    • Overview
    • Why Without JSX?
    • Installation
    • Usage
    • Styling
    • Theming
    • Portal System
    • Rules & Patterns
    • Framework Integration
    • Compiler
    • FAQ
    • Release Notes
  • MUI Integration
  • Components
  • Hooks

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:

FactoryUse caseExample
Node()One-off usage, especially for JSX componentsNode(TextField, { … })
createNode()Reusable factory, props-firstconst Field = createNode(Input)
createChildrenFirstNode()Reusable factory, children-firstconst Btn = createChildrenFirstNode(Button)
Component()Encapsulated logic + UI as a real React componentconst 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:

  1. Use .tsx file extensions
  2. Wrap them with Node() in the parent
  3. 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

SymptomCauseFix
"Rendered fewer hooks than expected"Hook-using component called directly inside a conditionalWrap with Node()
Style prop ignored on a custom componentCSS props are compiled to an Emotion class and handed over as className, which the component never appliesApply the incoming className to the component's root element
Node(MyComponent) throws / blank renderComponent returned a Node instance instead of a ReactElementAdd .render() to the top-level node
Custom prop appears in DOM / breaks layoutProp name matches a CSS property and got styledMove it under props: { yourProp }
as: MyComponent type erroras only accepts intrinsic HTML tagsUse Node(MyComponent, …) or a dedicated factory
Need link semantics on a styled blockUsing createNode('div') without hrefDiv({ as: 'a', href: '…', … }) or createNode('a', …)

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

On this page