Skip to content

Rules of Hooks

Overview

Call hooks at the top level of a component, in the same order, on every render. No loops, no conditions, no early returns, no nesting inside a function that runs later. ReactPy will not always tell you when you break this rule — in one case it corrupts state in silence.

@component
def form():
    name, set_name = use_state("")  # top level, every render, same order
    email, set_email = use_state("")
    return html.form(name, email)

ReactPy identifies a hook's state by where it was called, not by what it is called into. The nth use_state in a component body gets the nth stored slot. Change which hooks run, and you change which slot each one reads.


The rules

  • Only call hooks from a component body or another hook — never from an event handler, an effect body, a plain function, or module level. See React calls Components and Hooks.
  • Call them at the top level — not inside if, for, try, a while, a comprehension, or a nested function that runs later.
  • Always call the same hooks in the same order — which hooks run may not depend on props, state, or anything else that can change between renders.
  • No early return before a hook — an early exit skips the hooks after it.

Why: hooks are slots, not names

ReactPy keeps a component's hook values in a plain list and hands out the next entry on each call — a running index, not a name. From reactpy/core/_life_cycle_hook.py, LifeCycleHook.use_state:

if not self._rendered_atleast_once:
    # since we're not initialized yet we're just appending state
    result = function()
    self._state.append(result)
else:
    # once finalized we iterate over each succesively used piece of state
    result = self._state[self._current_state_index]
self._current_state_index += 1

The first render appends one entry per hook call, in order. Every later render reads back by position. Nothing records the variable name, the hook's type, or what it stores — so position is identity.

That is why the rules are phrased as "same order". The component cannot tell ReactPy which value is which; only the sequence of calls says so.


What breaking the rules actually does

This is where ReactPy and React part ways, and why the rules must be enforced by review rather than trusted to a loud failure.

More hooks than the first render: a crash

A component that mounts with one hook and later calls two tries to read a slot that was never appended:

@component
def grow():
    n, set_n = use_state(0)
    if n >= 1:  # a second hook appears only after the first click
        extra, set_extra = use_state("EXTRA")
        return html.div(f"n={n} extra={extra}")
    return html.div(f"n={n}", html.button({"onClick": lambda e: set_n(n + 1)}, "bump"))

With debug mode on, the component is replaced by an error node and the server logs the cause:

IndexError: list index out of range

With debug mode off, the error field is an empty string, so the region simply renders as nothing. This case fails loudly enough to find.

Fewer hooks than the first render: silent state corruption

This is the dangerous one. A hook call that disappears from above another one shifts every later index by one — and the shift is invisible:

@component
def shifting():
    show, set_show = use_state(True)  # slot 0
    if show:
        hidden, set_hidden = use_state("HIDDEN")  # slot 1, only while show
    n, set_n = use_state("N")  # slot 1 while show, slot 2 once it is off
    return html.div(f"show={show} n={n}")

Measured, clicking toggle once:

mount:          show=True  n=N
after toggle:   show=False  n=HIDDEN     <-- `n` is now reading `hidden`'s slot

No error, no warning, no traceback. n starts showing another hook's value and keeps doing so until the branches line up again. React, by contrast, throws Rendered fewer hooks than expected. A ported component can therefore be wrong in ReactPy where it was a crash in React.

The corruption usually surfaces later, somewhere that looks unrelated. With a counter that should hold an int, the shifted read hands a string to arithmetic, and the next click dies in the handler:

TypeError: can only concatenate str (not "int") to str

That traceback names the event handler, not the hook — so the hunt starts in the wrong file. The button stops working, and the error is on the server log only.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@component
def broken_toggle():
    """Do not copy this: a hook behind a condition that can change.

    `hidden` occupies slot 1 only while `show` is true. Clicking Toggle makes
    that call disappear, so every hook after it shifts up one slot and starts
    reading somebody else's value -- `count` begins showing the string that
    belongs to `hidden`. No error is raised, on the server or in the browser.
    """
    show, set_show = use_state(True)  # slot 0

    if show:
        hidden, set_hidden = use_state("NOT-A-NUMBER")  # slot 1, conditionally

    count, set_count = use_state(0)  # slot 1 when `show` is False

    return html.div(
        html.h1(count),
        html.p(f"show={show}"),
        html.button({"onClick": lambda event: set_show(not show)}, "Toggle"),
        html.button({"onClick": lambda event: set_count(count + 1)}, "Add 1"),
    )


@component
def fixed_toggle():
    """The same component with the hook order held constant.

    Every hook runs on every render, so the slots stay aligned with the
    variables they belong to. Branching happens in the *return value*, which is
    what rendering is for.
    """
    show, set_show = use_state(True)
    hidden, set_hidden = use_state("NOT-A-NUMBER")
    count, set_count = use_state(0)

    return html.div(
        html.h1(count),
        html.p(f"show={show} hidden={hidden}"),
        html.button({"onClick": lambda event: set_show(not show)}, "Toggle"),
        html.button({"onClick": lambda event: set_count(count + 1)}, "Add 1"),
    )
1
# TODO

In broken_toggle, Add 1 works, then Toggle makes the heading read NOT-A-NUMBER, and the next Add 1 does nothing at all — the handler raised TypeError on the server. fixed_toggle is the same component with the hooks moved out of the condition: all three buttons keep working.

A hook in a loop: frozen values, then a crash

@component
def rows(n):
    out = []
    for i in range(n):
        value, set_value = use_state(f"v{i}")
        out.append(value)
    return html.div(out)

Measured with n going 3 → 2 → 1 → 0:

vals=['v0', 'v1', 'v2']
vals=['v0', 'v1']
vals=['v0']
vals=[]

It looks correct. It is not: use_state(f"v{i}") applies its initial value only on the first render, so the i in it is decoration, and the list's length is driven by an argument while its contents come from frozen slots. Growing n past its first-render value raises the IndexError from the first case. A loop of hooks is wrong either way — build one component per item, which gives each its own state legitimately:

html.div(None, [row(item, key=item["id"]) for item in items])

An early return before a hook

@component
def loading():
    n, set_n = use_state(0)
    if n == 2:
        return html.div("early")  # skips the hook below
    label, set_label = use_state("LABEL")
    return html.div(f"{n} {label}")

ReactPy does not crash, and label survives while the branch is away. But this is the same slot-shifting hazard as above, arriving whenever the condition flips. Put the early return after every hook, or move the conditional content into a child component:

@component
def loading():
    n, set_n = use_state(0)
    label, set_label = use_state("LABEL")  # all hooks first
    if n == 2:
        return html.div("early")
    return html.div(f"{n} {label}")

Tolerance is not safety

ReactPy lets several of these through; React does not. The case above corrupts state without a word, and the same code raises the moment it is ported, tested against React semantics, or read by someone who assumes React's guarantees. Treat the rules as absolute.


Custom hooks follow the same rules

A custom hook is only a function, so nothing checks it:

def use_auth(token):
    if not token:
        return None, None  # hooks skipped on some calls
    user, set_user = use_state(None)
    return user, set_user

Call it with a falsy token and the caller silently loses two slots. Return early only after every hook has run, or split into two hooks and choose between them at the call site, where the choice is visible in the component body:

@component
def page(token):
    return authed(token) if token else anonymous()

Because a hook's name is all a reviewer has, name custom hooks use_* and call them only from component bodies or other hooks.


Catching violations

There is no ReactPy equivalent of eslint-plugin-react-hooks, and no runtime check. Two habits cover most real cases:

  • Run with REACTPY_DEBUG=1 during development, so a hook-count increase becomes a visible error instead of an empty region.
  • Treat any use_ call that appears below the first non-hook statement of a component body, or inside an if / for / try / nested def, as a review defect.

Reference

Pattern ReactPy behaviour Verdict
Hook at component top level Works Do this
Hook in a custom hook called from a body Works Do this
Hook in an if that never changes Works by accident Broken
Hook in an if that can change Fewer hooks: silent state corruption. More hooks: IndexError Broken
Hook in a for Frozen initial values; IndexError if the loop grows Broken
Hook after an early return Slot shift when the branch flips Broken
Hook in an event handler RuntimeError, server log only Broken
Hook in an effect body RuntimeError, server log only Broken
Hook at module level RuntimeError: No life cycle hook is active. Broken
Authors: Archmonger