Synchronizing with Effects
Overview¶
Some components need to synchronize with an external system: send an analytics log when a page appears, keep a third-party object in step with state, or hold a connection open while a view is on screen. Effects let you run code after a render so a component can talk to something outside of ReactPy.
You will learn
- What an Effect is, and how it differs from an event handler
- How to declare one, and when it re-runs
- Why ReactPy infers dependencies instead of requiring an array
- How to write a clean-up function, and what happens if you skip it
- How to read the newest state from a long-lived Effect
Note
Capitalized "Effect" means the ReactPy-specific idea above: a side effect caused by rendering. "side effect" keeps its ordinary programming meaning.
What are Effects and how are they different from events?¶
Every ReactPy component contains two kinds of logic:
-
Rendering code (introduced in Your First Component) sits at the top level of the component body. It takes props and state, does the math, and returns VDOM. Rendering code must be pure. It may only calculate; it may not do.
-
Event handlers (introduced in Responding to Events) are functions defined inside the component that ReactPy runs only when the user does something. They contain side effects — posting a form, updating a field — triggered by a specific interaction.
That covers most needs. But some work belongs to neither bucket. A chat view must connect to the server whenever it appears on screen, no matter which click or keystroke caused it to appear. There is no event for "this component was rendered", and putting the connection in the render body would break purity.
An Effect declares side effects caused by rendering itself, rather than by a particular event. It runs after the render is finished, in the connection's own task.
You might not need an Effect¶
Do not reach for an Effect first. Effects exist to step outside ReactPy and synchronize with something that is not ReactPy: a network connection, a queue, a third-party library, the filesystem. If your logic only adjusts one piece of state in response to another, or computes a value from state you already have, it belongs in the render — see You Might Not Need an Effect.
How to write an Effect¶
Three steps:
- Declare it. ReactPy registers the Effect for the render that created it.
- Say when it re-runs. By default ReactPy infers this from your closure, which is not what React does.
- Add a clean-up if it needs one. Everything that opens, subscribes, or schedules needs the matching close, unsubscribe, or cancel.
Step 1: Declare an Effect¶
Import the hook and call it at the top level of your component:
from reactpy import use_effect
Pass it a plain function that takes no arguments:
@component
def my_component():
def effect():
print("on screen")
use_effect(effect, dependencies=[])
...
Or use it as a decorator, which is equivalent:
@use_effect
def effect():
print("on screen")
Here is a working component. Open the server console and press Re-check: the log line appears once, on mount, and never again — the Effect does not depend on visit_count:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
1 | |
Two things about timing are worth stating precisely, because they differ from what a React tutorial will tell you:
- An Effect is scheduled after your component's render is complete, not after the browser has painted. The task is created at the end of the render (
core/_life_cycle_hook.py,affect_layout_did_render), and the update message has not necessarily reached the socket yet. Measured directly, an Effect's first line ran beforelayout.render()returned to the backend. So never assume the user can already see the result. - The Effect does not delay the render.
layout.render()does not await your effect body; the two interleave on the event loop. If you need ordering, express it in state: have the Effect set state and let the next render depend on it.
Effects run on the server
There is no document and no window. An Effect cannot measure an element, focus a field, or attach a scroll listener, because there is no DOM where it runs. For browser-side behaviour see Manipulating the DOM with Refs and Creating VDOM Event Handlers.
use_effect rejects async def
TypeError: `use_effect` does not support async functions. Use use_async_effect instead.
The failure is loud with REACTPY_DEBUG on: the model comes back as {"tagName": "", "error": "TypeError: ..."} and the region renders empty (with debug off you get the empty region and a traceback in the server log only). Anything await-based belongs in use_async_effect, whose effect takes zero arguments — the 1.x async def effect(stop) shape fails silently there instead.
Step 2: Specify the Effect dependencies¶
Pass the dependencies argument to control when the Effect re-runs — and note that ReactPy gives this argument four meanings where React has two:
| Value | When the Effect runs (verified) |
|---|---|
dependencies=[a, b] | On mount, then whenever a or b changes. |
dependencies=[] | Once, on mount. |
dependencies=None | After every render. |
| omitted | Inferred from the free variables of your closure. |
That last row is the one that bites people porting code from React. In React, omitting the array means "run after every render". In ReactPy it means "re-run when the names my closure reads change" — core/hooks.py, _try_to_infer_closure_values reads func.__closure__ and uses those cells as the dependency list. Here the Effect reads count and nothing else, so Rename re-renders the component without re-running the Effect:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
1 | |
Press Rename twice and no new log line appears; press Add 1 and one appears per increment. Verified.
Dependencies are not compared by identity
The docstrings for these hooks say dependencies are compared by id. They are not. core/hooks.py, strictly_equal gates on the type first, then uses ==, and for two locally defined functions it compares their bytecode. In practice: a freshly built [1, 2] does not re-trigger an Effect that listed the previous equal list, and neither does a structurally identical local def — which means an Effect listing a local function as its dependency keeps the closure it captured first and reads stale state permanently. Both were measured. List the values, not wrappers around them.
Write the list out
Inference is invisible, so an Effect that happens to read one extra local variable quietly gains a dependency, and one that reads nothing re-runs never. If the Effect runs more or less often than you meant, look at which names the body actually reads. An explicit list removes the guesswork, and dependencies=[] and dependencies=None are honest too.
Declare state before the Effects that read it
use_effect(effect) # effect reads `count`
count, set_count = use_state(0)
ReactPy reads the closure's cells while resolving dependencies, and count's cell is not filled yet: ValueError: Cell is empty. The component renders as a blank region, with the error in the model (debug on) or in the server log (debug off). State first, Effects after.
Step 3: Add clean-up if needed¶
Return a function from the Effect. ReactPy runs it before the next run of that same Effect and again when the component unmounts:
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 | |
1 | |
Switch rooms and the server log reads connect lobby, close lobby, connect dev — the teardown for the old room runs before the new one starts. Press Leave and the last connection closes. Verified.
This matters more in ReactPy than in React because of process lifetime. A closed browser tab destroys everything with it. A ReactPy server does not: a subscription you forgot to release is still live after the user went home, still holding your component, still firing callbacks into a view nobody can see. The counter-proof, measured in a Layout:
| Effect body | Subscribers after 4 renders | One publish fires | After unmount |
|---|---|---|---|
| returns nothing | 4 | 4 callbacks | 4 still attached |
returns unsubscribe | 1 | 1 callback | 0 |
Same component, same re-renders. Without the clean-up, one message ran the handler four times.
Clean-up is what makes restarts free
Whenever a dependency changes, ReactPy stops the old Effect and starts a new one. The rule of thumb is that nobody should be able to tell whether the Effect ran once or ran setup → cleanup → setup. Write it that way and restarts cost nothing; skip the clean-up and every dependency change leaks.
Reading the newest value from a long-lived Effect¶
Here is the trap that bites hardest with use_async_effect. An Effect's body closes over the render that created it. If that Effect then lives a long time — a polling loop, a subscription — it acts on values that are already stale.
The fix is to keep the value in a ref, assigning it during render. The ref object is the same one on every render, so a reader that wakes up later sees the newest write:
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 | |
Toggle the threshold from 50 to 10 and alerts start appearing; toggle back and they stop. The single long-lived task never restarts and always reads the current number. Verified.
The alternative — usually the better one — is to put the value in the dependency list so a change tears the Effect down and builds a new one with fresh values. Use the ref when restarting is expensive: an open socket, a loop with accumulated state.
Common patterns¶
Synchronizing with a non-ReactPy object¶
Third-party objects are imperative: you construct them, call setters, destroy them. State is the wrong home for one (constructing it would re-run each render, and setting it would trigger a render), so keep it in a ref and let Effects drive its lifecycle:
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 | |
1 | |
Two Effects, two dependency lists: one creates and destroys the object when name changes, the other pushes page_size into it. Verified against the object's own log: created q3, set_page_size letter, set_page_size a4, destroyed q3 on unmount — one instance, kept in step, cleaned up.
Two Effects in the same component run in declaration order, which is why create_engine is registered first: the second Effect can assume the object exists. That ordering holds within a component; across components it does not. Children's Effects run before their parent's, and siblings run in reverse order — measured ['child effect b', 'child effect a', 'parent effect']. Never write code that needs a parent to have run first.
Subscribing to a stream¶
The shape is always the same: subscribe inside the Effect, unsubscribe in the clean-up, name the topic in the dependency 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 35 36 37 38 39 40 41 42 | |
1 | |
Verified through the bus's own log: subscribe news, unsubscribe news, subscribe sports, unsubscribe sports, subscribe news, unsubscribe news across a full switch-and-leave cycle — exactly one subscriber live at any moment.
Note that the callback is defined inside the Effect, so it closes over this Effect's values and the subscription is rebuilt when the dependencies change. Defining it in the component body instead would leave the subscription holding the first version of it.
Fetching data¶
Fetching belongs in use_async_effect, and cancellation is what makes it safe:
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 | |
The example is deliberately lopsided: orders takes 0.8 s and users takes 0.05 s. Press Toggle report while the slow one is in flight and the screen settles on users: 500 rows and stays there. The data source recorded that it computed only the users answer — the orders task was cancelled inside its await and never reached its set_rows. Verified.
That is ReactPy's version of React's ignore flag, and it is stronger: the cancelled run's remaining lines never execute at all. Two consequences:
- Code after an
awaitmay never run. Anything that must happen — closing a handle, releasing a lock — goes in atry/except asyncio.CancelledErrorpair, and re-raises so asyncio can finish cancelling. - An Effect that sets state it depends on loops forever. If a component appears to spin, read what the Effect awaits, what it depends on, and what it sets.
Good alternatives to fetching in an Effect
Manual fetching in an Effect works, and carries the same drawbacks here as in React: no deduplication, no cache shared between components, and a request that cannot start until the component exists. ReactPy has a better option available by default — fetch during a parent's render, or at startup, and pass the result down as a prop. The child then renders with data instead of loading it. Keep the Effect for data that genuinely depends on something only the connection knows, such as use_location.
Sending analytics¶
Report a page view when the page changes, not when the component happens to render:
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 | |
1 | |
The Effect depends on path only. Type into the field and the report count stays put; press Navigate and it climbs by one. Verified: two typing re-renders produced zero reports, two navigations produced two.
Not an Effect: initializing the application¶
Logic that should run once for the whole process does not belong in a component at all — a component's Effect runs once per connection, and a server serves many:
# module level: runs when the module is imported, once
configure_logging()
POOL = create_pool()
Put it in the ASGI app's startup, or in the module that builds the ReactPy executor. Reach for a dependencies=[] Effect only when the work is genuinely per-connection. Never guard it with my_component.started = True: the function object is module-scope, so the first visitor flips the flag for everyone (VDOM Mutations).
Not an Effect: buying a product¶
An Effect is not the place for something that must happen exactly once per user action, because you cannot promise how many times an Effect runs — dependencies change, components unmount, processes restart. Charging a card belongs in an event handler, where a click is the only trigger:
def handle_buy(event):
set_status("buying...")
order = place_order(sku)
set_status(f"ordered {order.id}")
Deep dive: each render has its own Effects¶
An Effect belongs to the render that created it. Its closure holds that render's props and state, and it never sees a later one. The dependency list chooses which render's Effect survives; it does not update the one that did:
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 | |
1 | |
Press Type ! twice, then Switch room. The log gains lobby <- hello from the mount, and then dev <- hello!! — the Effect that ran for dev was created by the render whose message was already 'hello!!'. Verified. No entry ever records an intermediate message, because only the renders whose dependencies changed created an Effect.
This is why "my Effect reads an old value" has exactly two correct answers: add the value to the dependency list, or read it through a ref.
There is no double-invoke in development¶
React remounts every component twice under Strict Mode to expose Effects that are not restart-safe. ReactPy has no Strict Mode and never calls your component twice — verified: a component that increments a counter in its own body counted exactly one render per render.
Two consequences:
- Nothing will find your unclean Effects for you. The restart-safety check React gives away for free is one you have to run yourself: render the component, unmount it, mount it again, and assert the external system is back where it started. A
reactpy.core.layout.Layoutin a pytest makes that a ten-line test. - Do not add artificial guards to compensate.
if already_ran.current: returninside an Effect is evidence the dependency list is wrong, not a fix. Write the clean-up so a restart is free and the question stops mattering.
Recap¶
- An Effect is a side effect caused by rendering; an event handler is one caused by a user action. Prefer the handler.
- Effects are scheduled after the render completes, in the connection's own task. They do not block the render, and the browser may not have the update yet.
- They run on the server: no DOM, no
window, no element to focus. use_effecttakes a plaindef;use_async_effecttakes a zero-argumentasync def. One mismatch fails loudly, the other (the oldasync def effect(stop)shape) fails silently.dependencieshas four settings. Omitted means inferred from the closure, not "every render" — and the comparison is==, not identity.- Return a clean-up function. It runs before the Effect's next run and on unmount. Skipping it leaks, and a leak in a long-lived server process leaks forever.
- A long-lived Effect sees a frozen snapshot; read current values from a ref, or restart the Effect by depending on them.
Related¶
- Use Effect and Use Async Effect — full parameter reference
- You Might Not Need an Effect — the cases that belong back in the render
- Separating Events from Effects — reading the newest state without restarting an Effect
- Removing Effect Dependencies — when to omit a dependency, safely
- Referencing Values with Refs — the box that survives renders