Reusing Logic with Custom Hooks
Overview¶
A custom Hook is a plain Python function whose name starts with use_ and which calls other Hooks. It lets you reuse stateful logic between components — each caller still gets its own state, and nothing about it is special to ReactPy beyond the naming rule that makes the linter work.
You will learn
- How to extract a custom Hook, and what it does and does not share
- Why each call site gets independent state
- Why passing a callback into a Hook silently freezes it
- What actually happens when a Hook is called conditionally
- What
reactpy-flake8can and cannot see inside a custom Hook
Custom Hooks: sharing logic between components¶
Consider a shopping list where each row can be selected. The selection logic is stateful, and a second screen will need it too. Pulling it out gives you a function that reads like a Hook and behaves like one:
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 | |
1 | |
Verified: pressing apple, cheese, bread, then apple again walks the counter 1 → 2 → 3 → 2 and the total follows it. Nothing about use_cart is registered, exported, or declared — it is an ordinary function that happens to call use_state.
Two rules make it a Hook rather than a helper:
- Its name starts with
use_. This is not cosmetic; see below. - It is called during rendering, at the top level of a component or another Hook — never in an
if, a loop, a callback, or after an early return.
Hook names must match ^_*use_\w+$¶
reactpy_flake8 decides what counts as a Hook with a regex, DEFAULT_HOOK_FUNCTION_PATTERN = r"^_*use_\w+$" (reactpy_flake8/run.py). Measured against the same file with only the function name changed, calling it conditionally from a component:
| Function name | Reported |
|---|---|
use_form | REACTPY102 — hook used inside if |
_use_form | REACTPY102 |
useForm | REACTPY101 — hook used outside component or hook |
get_form | REACTPY101 |
So the naming rule is what buys you the good diagnostic. A camelCase useForm still gets flagged — but as REACTPY101 at every use_state line inside it, which points at the wrong thing and reads like you called a Hook from the wrong place. Name them use_snake_case, and the linter can tell control-flow violations apart from misplaced calls.
Not every render-time function needs the prefix
Add use_ when the function calls Hooks. Leave it off when it does not — a format_rows() or total_price() is an ordinary function, and prefixing it would make the linter police it as a Hook.
Custom Hooks share logic, not state¶
A common fear is that a Hook is a singleton. It is not: state is stored per call site, in order. Call the same Hook twice in one component and you get two independent boxes.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
1 | |
Verified: pressing left gives left ON right off; pressing right gives left ON right ON; pressing left again gives left off right ON. Same function, same arguments, two unrelated pieces of state — and both on only when both are flipped.
The same Hook called from two different components is equally independent, since each component instance has its own Hook slots. There is no shared registry, no module-level store, and no Context involved.
This is also why a custom Hook is not a place to put values you want everyone to see. For that, pass props or use create_context (Passing Data Deeply with Context).
Passing reactive values between Hooks¶
A Hook receives plain values during render and returns plain values. It re-runs whenever its caller re-renders, so it sees the same props and state the caller does.
def use_selection(items, limit):
selected, set_selected = use_state([])
too_many = len(selected) > limit # re-evaluated every render
return selected, set_selected, too_many
What you cannot do is pass a callback in and list it as a dependency.
The callback you pass in will go stale¶
use_polling below takes a result handler and lists it as the Effect's dependency — which looks exactly right, and never works:
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 | |
1 | |
Verified: press Topic once so the page reads Topic newsx, and the line still says handler saw topic=news. The poll counter keeps climbing, so it looks alive — but it is calling the handler from the first render, forever.
The reason is in core/hooks.py, strictly_equal: a def nested in a component is compared by its co_* bytecode attributes, not by identity. on_result re-created on the next render compares code-equal, so the dependency never changes and the Effect is never replaced.
The fix is to keep the callback in a ref that is refreshed during render, and leave it out of the list:
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 | |
1 | |
Verified: after changing the topic, the same line reads (topic was newsx). The Effect still has exactly one dependency; only the ref's contents changed.
This is the same trap as a locally defined function used as an Effect dependency (Lifecycle of Reactive Effects), and it is nastier inside a Hook because the code that lists the dependency and the code that created the callback live in different files.
When a Hook is called conditionally, nothing fails¶
This is the section React's version of the page does not need, because React's compiler and dev-mode warnings are not present here.
ReactPy matches Hooks to their state slots by call order. Nothing names them, and nothing checks them. When the number of Hooks called changes between renders, the slots shift and values land in the wrong variables:
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 | |
1 | |
Verified: press bio= twice, then toggle nickname. The page prints
bio=bio!! # before the branch changes
bio=nickname # after — bio is now nickname's state
No exception. No warning. No log record — the probe captured a completely silent reactpy logger across the whole transition. bio is the third Hook when the branch is taken and the second when it is not, so after the flip it reads the box that nickname used to own.
The corrected version calls every Hook unconditionally and makes only the return value conditional:
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 | |
1 | |
Verified with the same script: bio=bio!! before and after the toggle. The toggle now changes only what is displayed.
What the linter can see¶
REACTPY102 does fire on the broken file above — hook 'use_state' used inside if statement — provided the function is named as a Hook. That coverage is real and worth wiring into CI. It does not extend to:
- calling a Hook-conditionally through a wrapper that the linter does not recognise (see the name table above),
- an early
returnthat skips Hooks declared after it — the pattern people reach for when "toggling off" a feature, - a Hook list in one branch matching the caller's expectation in another.
And it never checks dependency lists, which is the other rule people break. Treat the linter as a partial net and the fix — Hooks at the top level, always — as the actual rule.
Do not branch to disable a Hook
# wrong: the second Hook disappears when polling is off
if polling:
use_async_effect(loop, dependencies=[topic])
None when there is nothing to do. See Lifecycle of Reactive Effects. Passing event handlers into custom Hooks¶
A handler is an ordinary value: pass it in, call it, done — it does not need to be a dependency, and listing it does not make it fresher (measured above). The two shapes that work:
# the Hook calls it; the ref keeps it current
def use_polling(on_result):
latest = use_ref(on_result)
latest.current = on_result
...
# or the caller owns the callback entirely and the Hook just hands back a value
selected, toggle = use_cart(items)
If you find yourself wanting to store a callback so it stays fresh, that is the same problem useEffectEvent solves in React — and ReactPy has no such Hook. The ref-updated-during-render idiom above is the equivalent, with the caveat that reading .current during render makes the render depend on an external value (Manipulating the DOM with Refs).
When to use custom Hooks¶
Write one when you have logic — not markup — that two components genuinely need in the same shape. Good candidates in ReactPy:
- selection, pagination, and form-draft logic
- a subscription or polling loop with its clean-up wired correctly
- reading a value from a
use_connection-scoped resource - a compound updater that several components apply to the same kind of state
Keep them focused on a concrete use case. A use_async(fetcher, deps) that wraps use_async_effect in generic terms tends to encode one person's cancellation assumptions for everyone; the specific use_polling(topic, on_result) is easier to reason about and easier to change.
Two things a custom Hook is not:
- A render bailout. ReactPy has no
memo, so extracting a Hook does not reduce how often a component's body runs (Separating Events from Effects). - A state container. It cannot share state between components; only props and Context can.
Recap¶
- A custom Hook is a function named
use_*that calls Hooks; extract one to reuse stateful logic. - State is per call site, in call order: the same Hook called twice is two independent boxes (verified).
- Name them
use_snake_case— the linter's regex is^_*use_\w+$, and camelCase versions get flagged asREACTPY101instead of the accurateREACTPY102. - Never list a callback you were handed as a dependency: it compares code-equal and freezes the Effect on the first render. Read it through a ref updated during render.
- Conditional Hook calls do not raise; state slots shift and values silently migrate between variables (verified:
biobecamenickname). Call every Hook unconditionally and branch on the return value. reactpy-flake8sees control-flow violations inside recognised Hooks, and nothing else — no dependency checks, no wrapper coverage.
Related¶
- Synchronizing with Effects — the Effect patterns a Hook will most often wrap
- Lifecycle of Reactive Effects —
strictly_equal, frozen closures, and silent Effect failures - Separating Events from Effects — handlers, and why identity buys nothing here
- Rules of Hooks and Components and Hooks Must Be Pure — the constraints a Hook inherits
- Use State and Use Ref — parameter-level reference