Skip to content

Removing Effect Dependencies

Overview

Every dependency you list is a promise: "when this changes, redo the work." Removing one you don't need is fine — and proving it is not needed is the whole job. This page covers what counts as a change in ReactPy, and the six legitimate reasons a value can leave the list.

You will learn

  • How to prove a value is not a dependency, rather than guess
  • How ReactPy actually compares dependencies — not what the docstring says
  • The six safe ways to remove a dependency
  • Why a rebuilt object is not "new" here, and when that saves you
  • What to do when a value must stay out of the list for real

Dependencies should match the code

The list describes the code, not your intent. Look at the Effect body, list every name it reads that came from the component body, and that is the list. Nothing more, nothing less.

A missing dependency is a correctness bug: the Effect reports values from an older render. An extra dependency is a behaviour bug: the work runs when it shouldn't, tearing down something that should have stayed up. Both are worth fixing, and they are fixed differently.

To remove a dependency, prove one of these about it:

  • it cannot change between renders (module constant, imported helper)
  • it is read only to compute a new value, not to decide what to do
  • it is read for a "latest value" snapshot, and reacting to it is unwanted
  • the code that reads it should be in an event handler, not an Effect
  • the Effect is doing two jobs and only one of them needs it
  • it changes unintentionally, and the real fix is to stop it changing

If none of those is true, the value stays. Deleting it from the list to quiet a symptom produces an Effect that looks fine and reports the past.

A missing dependency is visible if you look

This Effect writes a summary into state but lists only topic:

 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
@component
def reporter(topic):
    count, set_count = use_state(0)
    theme, set_theme = use_state("light")
    summary, set_summary = use_state("no report yet")

    def report():
        # reads `count` and `theme`, but neither is in the dependency list
        set_summary(f"topic={topic} count={count} theme={theme}")
        print(f"report topic={topic} count={count} theme={theme}")

    use_effect(report, dependencies=[topic])

    return html.div(
        html.button({"onClick": lambda e: set_count(count + 1)}, f"Count {count}"),
        html.button(
            {"onClick": lambda e: set_theme("dark" if theme == "light" else "light")},
            f"Theme {theme}",
        ),
        html.p(f"live: count={count} theme={theme}"),
        html.p(f"summary: {summary}"),
    )


@component
def app():
    topic, set_topic = use_state("news")
    return html.div(
        html.button({"onClick": lambda e: set_topic(topic + "x")}, f"Topic {topic}"),
        reporter(topic),
    )
1
# TODO

Verified: press Count twice and Theme once, and the page reads

live:    count=2 theme=dark
summary: topic=news count=0 theme=light

The summary describes the render that created the Effect. Press Topic and both lines agree again — because a new Effect was built from the current render. That pair of lines is the whole missing-dependency bug made visible: it is not frozen at mount, and it is not current. It is from some render.

How ReactPy compares dependencies

This is where React's advice and ReactPy's behaviour part ways, and it decides which removals are safe.

Dependencies go through core/hooks.py, strictly_equal:

  1. Type gate first. If type(x) is not type(y), it is a change.
  2. Local functions compare code. For callables whose __qualname__ contains <locals> or <lambda>, the co_* attributes of the bytecode are compared — same code means unchanged.
  3. Otherwise ==, falling back to is if == raises.

Note step 3. The strictly_equal docstring claims only int, float, str, bytes and friends are compared by equality, but the implementation reaches x == y for anything with an __eq__ — which is everything. Measured over 5 renders each (mount plus four unrelated state changes), counting how many times the Effect ran:

Dependency, rebuilt on every render Runs
{"sort": "date", "limit": 10} — never changes 1
[1, 2, 3] — never changes 1
DataFilter("a") (@dataclass) — content changed twice 3
TupleFilter("a") (NamedTuple) — content changed twice 3
{"sort": "date", "limit": count} — content tracks state 3
plain class instance, no __eq__ 5 (every render)
(1, 2, 3) alternating with [1, 2, 3] 3 (type gate)

Consequences:

  • React's "a rebuilt object is a new dependency" warning does not apply here. A dict or list you rebuild on every render is not a dependency change, so it will not loop. That is why the classic infinite-loop example needs no fix in ReactPy — and why you must not rely on it either, since it depends on values comparing equal, not on identity.
  • A plain class instance always counts as changed, because it inherits object.__eq__ and identity never holds across renders. That is the shape that genuinely spins.
  • The type gate catches things == would not: strictly_equal(1, True) is False even though 1 == True, and a tuple swapped for a list is a change.

Because ReactPy cannot check your list for you — reactpy-flake8 reports a missing dependency only for an inline lambda, never for a named def or a decorated Effect — proving the list is yours to do. See Separating Events from Effects and Lifecycle of Reactive Effects for the linter's exact coverage and for the Layout-based test that catches what it cannot.

Removing unnecessary dependencies

Should this code move to an event handler?

If the work should happen because the user did something, it belongs in the handler, and the dependency disappears along with the Effect. This is the most common removal and the one that deletes the most code — see Separating Events from Effects.

Is your Effect doing several unrelated things?

One Effect connecting a socket and loading rows means each job inherits the other's dependencies:

 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
@component
def feed(topic):
    page, set_page = use_state(1)
    rows, set_rows = use_state([])
    online, set_online = use_state(True)

    def subscribe():
        # one Effect doing two unrelated jobs: connect AND load rows
        if not online:
            print("offline — nothing to do")
            return
        print(f"connect {topic} page={page}")
        set_rows([f"{topic} row {i} (page {page})" for i in range(3)])

        def close():
            print(f"disconnect {topic} page={page}")

        return close

    use_effect(subscribe, dependencies=[topic, page, online])

    return html.div(
        html.button({"onClick": lambda e: set_page(page + 1)}, f"Page {page}"),
        html.button({"onClick": lambda e: set_online(not online)}, "Toggle online"),
        html.ul([html.li({"key": row}, row) for row in rows]),
    )


@component
def app():
    topic, set_topic = use_state("news")
    return html.div(
        html.button({"onClick": lambda e: set_topic(topic + "x")}, f"Topic {topic}"),
        feed(topic),
    )
1
# TODO

Verified: press Toggle online and the log shows disconnect news page=2 before offline — nothing to do. Going offline tears down a connection by way of a page number. Split the Effect in two and each list shrinks to what its own job reads.

Are you reading some state to calculate the next state?

Then that state is not a dependency — it is an input to the next value. Take an updater and it leaves 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
@component
def log_viewer(topic):
    lines, set_lines = use_state([])
    visible, set_visible = use_state(3)

    def record():
        # `lines` is only used to build the next value, so it is not a
        # dependency — and listing it would make this Effect re-trigger itself
        def append(current):
            return [*current, f"opened {topic}"]

        set_lines(append)

    use_effect(record, dependencies=[topic])

    return html.div(
        html.button({"onClick": lambda e: set_visible(visible + 1)}, f"Show {visible}"),
        html.ul([html.li({"key": i}, line) for i, line in enumerate(lines[-visible:])]),
    )


@component
def app():
    topic, set_topic = use_state("news")
    return html.div(
        html.button({"onClick": lambda e: set_topic(topic + "x")}, f"Topic {topic}"),
        log_viewer(topic),
    )
1
# TODO

Verified: each topic change appends exactly one entry; changing Show does not append anything.

Now the reason this rule matters. Listing lines as well — "just to be safe", exactly the mistake the ReactJS page warns about in the other direction — gives:

def record():
    def append(current):
        return [*current, f"opened {topic}"]

    set_lines(append)

use_effect(record, dependencies=[topic, lines])   # never settles

Measured: the connection rendered 400 times without settling and the component body ran 800 times. The Effect appends, lines changes, the Effect runs again. A functional updater is not a micro-optimisation here; it is what breaks the cycle.

Do you want to read a value without reacting to its changes?

Keep it in a ref that you update during render, and leave it out of the list. The difference is easy to see on a poll loop with a user-adjustable delay.

The version that lists the delay:

 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
@component
def poller(topic):
    delay, set_delay = use_state(0.2)
    polls, set_polls = use_state(0)

    async def loop():
        print(f"start polling {topic} delay={delay:.1f}s")
        try:
            while True:
                await asyncio.sleep(delay)
                print(f"tick {topic} delay={delay:.1f}s")
                # the loop is created once per run, so read state via an updater
                set_polls(lambda current: current + 1)
        except asyncio.CancelledError:
            print(f"stop polling {topic} delay={delay:.1f}s")
            raise

    # `delay` here means adjusting it cancels and recreates the whole loop
    use_async_effect(loop, dependencies=[topic, delay])

    return html.div(
        html.p(f"{topic}: {polls} polls"),
        html.button(
            {"onClick": lambda e: set_delay(delay + 0.2)}, f"Delay {delay:.1f}s"
        ),
    )


@component
def app():
    topic, set_topic = use_state("news")
    return html.div(
        html.button({"onClick": lambda e: set_topic(topic + "x")}, f"Topic {topic}"),
        poller(topic),
    )
1
# TODO

The version that keeps it in a ref:

 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
@component
def poller(topic):
    delay, set_delay = use_state(0.2)
    polls, set_polls = use_state(0)
    # updated during render, so the loop always sleeps with the newest value
    delay_ref = use_ref(delay)
    delay_ref.current = delay

    async def loop():
        print(f"start polling {topic}")
        try:
            while True:
                await asyncio.sleep(delay_ref.current)
                print(f"tick {topic} delay={delay_ref.current:.1f}s")
                # the loop is created once, so read state through an updater
                set_polls(lambda current: current + 1)
        except asyncio.CancelledError:
            print(f"stop polling {topic}")
            raise

    # `delay` is deliberately absent: adjusting it must not reconnect
    use_async_effect(loop, dependencies=[topic])

    return html.div(
        html.p(f"{topic}: {polls} polls"),
        html.button(
            {"onClick": lambda e: set_delay(delay + 0.2)}, f"Delay {delay:.1f}s"
        ),
    )


@component
def app():
    topic, set_topic = use_state("news")
    return html.div(
        html.button({"onClick": lambda e: set_topic(topic + "x")}, f"Topic {topic}"),
        poller(topic),
    )
1
# TODO

Verified with the same script on both — two delay adjustments, half a second of ticking, then a topic change:

Loop starts Loop stops
delay in the list 4 2
delay in a ref 2 1

Two of those restarts are pure waste: adjusting a setting nobody asked to reconnect for. And because the ref is assigned during render, the loop still sleeps with the newest delay — tick news delay=0.6s appears in the log of the ref version even though the Effect never re-ran.

The ref assignment during render looks like a write in a render, and it is the one case that is safe: the box is created once, the write is idempotent, and nothing derived from it feeds the render output.

Does some reactive value change unintentionally?

Sometimes the dependency is correct and the value is the problem.

Move static values out of the component. A module-level constant, an imported helper, or a frozen config is the same object on every render and every connection, so it is not a dependency at all:

 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
PAGE_SIZE = 20
HEADERS = {"Accept": "application/json"}


def format_rows(topic, raw):
    """Module-level helper: same object on every render and every connection."""
    return [f"{topic}: {name}" for name in raw]


@component
def directory(topic):
    query, set_query = use_state("")
    rows, set_rows = use_state([])

    def load():
        raw = [f"user-{i}" for i in range(PAGE_SIZE // 4)]
        print(f"load topic={topic} page_size={PAGE_SIZE} headers={HEADERS}")
        set_rows(format_rows(topic, raw))

    # neither PAGE_SIZE, HEADERS, nor format_rows belongs in this list
    use_effect(load, dependencies=[topic])

    return html.div(
        html.input(
            {
                "value": query,
                "onChange": lambda e: set_query(e["target"]["value"]),
            }
        ),
        html.p(f"query={query!r} rows={len(rows)}"),
        html.ul([html.li({"key": row}, row) for row in rows]),
    )


@component
def app():
    topic, set_topic = use_state("news")
    return html.div(
        html.button({"onClick": lambda e: set_topic(topic + "x")}, f"Topic {topic}"),
        directory(topic),
    )
1
# TODO

Verified: typing in the box re-renders the component and the Effect does not run; only the topic change logs load topic=newsx page_size=20 headers={'Accept': 'application/json'}.

Read primitive values out of objects. user.id is a dependency; user is a wider promise than you need, and any unrelated field change re-runs the Effect. Depend on the scalars you actually use.

Calculate primitives from functions, not objects. This is also the fix for the trap that silently freezes an Effect: a locally defined function passed as a dependency compares code-wise and therefore never changes. Depend on the string or number the function would have produced.

Rebuilding an object is not a dependency change — but don't rely on it

 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
@component
def feed(topic):
    page, set_page = use_state(1)
    rows, set_rows = use_state([])

    # rebuilt on every render, but compares equal to the previous one
    options = {"topic": topic, "per_page": 20}

    def load():
        print(f"loading {options} page={page}")
        set_rows([f"{topic} row {i} (page {page})" for i in range(3)])

    use_effect(load, dependencies=[options, page])

    return html.div(
        html.button({"onClick": lambda e: set_page(page + 1)}, f"Page {page}"),
        html.ul([html.li({"key": row}, row) for row in rows]),
    )


@component
def app():
    topic, set_topic = use_state("news")
    refreshes, set_refreshes = use_state(0)
    return html.div(
        html.button(
            {"onClick": lambda e: set_refreshes(refreshes + 1)},
            f"Re-render ({refreshes})",
        ),
        feed(topic),
    )
1
# TODO

Verified: press Re-render three times, which rebuilds options every time — the Effect runs zero extra times. Press Page and it runs, because page is a real dependency.

It is useful to know this: query-option dicts and filter objects do not have to be use_memo-ed to avoid a reconnect storm. But it is a behaviour, not a contract. It holds only while your values compare equal, and it silently inverts for a plain class instance (5 runs out of 5 renders above). Write the list you mean, and let equality be a bonus.

When a dependency must come out

There are cases where the honest answer is "I read this value, I do not want to react to it, and a ref is the wrong shape for it." Then be explicit about the exception rather than quietly deleting the name:

def effect():
    notify(user_id, channel_id)   # reads channel_id

use_effect(effect, dependencies=[user_id])
# channel_id is read but not reacted to: it changes only when the user
# switches channel, which unmounts this component anyway.

The comment is the review surface. An unexplained missing name is indistinguishable from the bug in the first example on this page — and, as measured, nothing in ReactPy will tell you which one you wrote.

What dependencies=None does not mean

None is not "no dependencies"; it means re-run after every render. To create the Effect once and never replace it, use dependencies=[] — which also seals the body against future renders. See the four-way table in Lifecycle of Reactive Effects.

Recap

  • The list describes the code. Prove a value is not a dependency; don't vote it off.
  • ReactPy compares dependencies with strictly_equal: type gate, then code comparison for local functions, then ==. Rebuilt dicts and lists are not changes; plain class instances always are.
  • A missing dependency is silent — no warning, no log. Make the staleness visible in the page, as the first example does, or assert it in a test.
  • The six removals: hoist what cannot change, take an updater for next-state reads, keep latest-value reads in a ref, move interaction logic to handlers, split Effects that do two jobs, and stop values changing unintentionally.
  • Listing state you only append to is the classic self-triggering loop: measured, 400 renders and no settle.
  • reactpy-flake8 will not check the list for you, so an explanatory comment is the only reviewer you have.
Authors: Archmonger