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.

The one exception is use_context: it takes no slot at all (core/hooks.py reads the provider off the current hook and never touches the state list), so calling it conditionally shifts nothing — verified, a component whose use_context call runs on only some renders keeps _state at the same length throughout. It is still a hook and still needs an active render to call.


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)

    async def bump(event):
        set_n(n + 1)

    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": bump}, "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, flipping show from True to False once (through its setter — this snippet has no button; the runnable broken_toggle below wires the same shape to a real Toggle button):

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
43
44
45
46
47
48
49
50
51
52
53
54
@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

    async def handle_show_click(event):
        set_show(not show)

    async def handle_count_click(event):
        set_count(count + 1)

    return html.div(
        html.h1(count),
        html.p(f"show={show}"),
        html.button({"onClick": handle_show_click}, "Toggle"),
        html.button({"onClick": handle_count_click}, "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)

    async def handle_show_click(event):
        set_show(not show)

    async def handle_count_click(event):
        set_count(count + 1)

    return html.div(
        html.h1(count),
        html.p(f"show={show} hidden={hidden}"),
        html.button({"onClick": handle_show_click}, "Toggle"),
        html.button({"onClick": handle_count_click}, "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: both of its buttons keep working through every toggle.

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 a slot — measured, the happy path consumes one hook slot and the falsy path consumes none. 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 a linter: reactpy-flake8, the plugin this repository pins (reactpy-flake8 >=0.7 in pyproject.toml, select = ["RPY"]). Its RulesOfHooksVisitor is the working equivalent of eslint-plugin-react-hooks for the call-site rule, and it reports:

Code Catches
REACTPY100 a hook defined as a closure inside another function
REACTPY101 a hook used outside any component or hook
REACTPY102 a hook used inside an if / if expression / try / match / for / while
REACTPY103 a hook used after an early return

Run it as part of flake8 (the repo config selects only its RPY codes), or drive it directly:

import ast
from reactpy_flake8.run import run_checks

for line, col, msg in run_checks(ast.parse(source), exhaustive_hook_deps=False):
    print(line, col, msg)

Verified against a component with a hook after an early return and a hook inside an if: it reports exactly REACTPY103 hook 'use_state' used after an early return on line 7 and REACTPY102 hook 'use_state' used inside if statement. Pass exhaustive_hook_deps=True to add the dependency-shape checks (REACTPY200–REACTPY202), which cover far less than people expect — see Lifecycle of Reactive Effects.

What the linter cannot do is check the order rule, because order is a runtime property. There is still no runtime check, so the two habits remain:

  • 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 — and wire reactpy-flake8 into CI so you are not the only one who spots it.

The linter also matches on names: it flags anything whose name fits ^_*use_\w+ and treats a function as a component only if decorated with something matching ^(component|[\w.]+\.component)$. Rename the decorator or the hook and the checks silently stop applying; the patterns are configurable via --component-decorator-pattern and --hook-function-pattern.


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