Updating in place
.widget from a @reactive.effect, the self-trigger loop reactive.isolate() exists to prevent, what every renderer’s own “Updating the widget from Python” section teaches, when an in-place update doesn’t actually save bytes, and the three lifetimes a widget can have – scoped to one render, cached for a session, or shared by every user at once.
A render function that depends on a changed input runs again and returns a brand new widget: new zoom level, new selection, new everything, and (for an anywidget-based widget) another full copy of its JavaScript bundle sent to the browser. Most of the time that’s exactly what you want – the shape of the data changed, so a fresh widget is the simplest correct thing to build. But when the change is naturally “update this one thing” – recolor a trace, toggle a layer, scrub a frame – re-rendering throws away state the user was relying on for no reason. The alternative is to mutate the existing widget from a @reactive.effect instead of returning a new one from the render function: only the changed trait crosses the wire, and everything else about the widget – camera, zoom, pan, in-progress selections on other accessors – stays exactly where the user left it.
The performance article measures exactly how much this costs: a plotly marker-color update is 36.3 KB in place vs. 5.45 MB on re-render (146x), because re-rendering resends the entire plotly.js bundle as part of the widget’s own state; lonboard is 141.8 KB vs. 6.19 MB (43x) for the same reason. Those numbers (and the methodology behind them) live there, not here – this article is about the mechanics and the judgment call, not the measurement.
Writing back
Update the existing widget in place from a @reactive.effect instead of re-rendering:
# Updating a widget in place from an effect, instead of re-rendering it.
# Each click on the map adds a marker at that spot via `map.widget.add(...)`
# -- the SAME Map instance keeps running, so whatever the user already
# panned/zoomed to survives. Re-rendering the map from the click (e.g. by
# making `map()` read a `reactive.value` that the effect sets) would send a
# brand new Map to the browser and reset the zoom/center every time.
#
# The counter is bumped with `reactive.isolate()`. Without it, this effect
# would read `clicks()` (to compute `clicks() + 1`) AND set it in the same
# run, so `clicks.set(...)` would invalidate the very effect that's running
# -- which reschedules itself, reads `clicks()` again, sets it again, and
# never stops: a silent infinite loop that pins a CPU core and freezes the
# whole app (no error, no output change -- see the article text for how to
# recognize this).
from ipyleaflet import Map, Marker
from shiny import reactive
from shiny.express import render, ui
from shinywidgets import render_ipyleaflet
ui.page_opts(title="Writing back", fillable=True)
clicks = reactive.value(0)
with ui.layout_columns(col_widths=[8, 4]):
with ui.card(full_screen=True):
ui.card_header("Click the map to drop a marker (zoom/pan are preserved)")
@render_ipyleaflet
def map():
return Map(center=(45.5, -122.6), zoom=9)
with ui.card():
ui.card_header("Markers added")
@reactive.effect
def _add_marker():
pt = map.clicked()
if pt is None:
return
lat, lng = pt["coordinates"]
map.widget.add(Marker(location=(lat, lng), draggable=False))
with reactive.isolate():
clicks.set(clicks() + 1)
@render.code
def count_out():
return f"{clicks()} marker(s) added"Before any clicks, the map is zoomed to street level and no markers exist:

Three clicks later, three markers sit on the same Map instance, at the exact same zoom level – nothing about the view was touched:

Notice reactive.isolate() around the counter update. This is not a style preference – skip it and the app silently hangs. clicks.set(clicks() + 1) first reads clicks() to compute the new value. Read inside a @reactive.effect, that read makes the effect depend on clicks; the very next line then sets clicks, which invalidates every reader of it – including this same effect. The effect reschedules, reads clicks() again, sets it again, forever: a tight loop that pins a CPU core and never produces an error, a changed output, or any other visible sign that something is wrong. The rule: any time an effect reads-and-writes its own reactive state (a counter, an accumulator, anything of the shape x.set(x() + ...)), wrap the read in reactive.isolate(). The mosaic guide and the ipydatagrid guide both use this exact idiom to count accessor fires without looping, if you want a second worked example.
What each widget calls in-place
Every renderer exposes a read-only .widget (or, for a handful, a .value) once it has rendered – the live object the browser is already showing. What’s worth mutating on it is different per library, because each library’s own object model is different. Each guide’s own Updating the widget from Python section is the full, verified version with a runnable example; this table is the index into all of them, in one place:
| Widget | In-place API |
|---|---|
| plotly | .widget traces under batch_update() (trace.marker.color = ...) |
| altair | chart.widget.params.<name> = value (selections are read-only from Python) |
| bokeh | mutate a kept ColumnDataSource/model reference directly |
| matplotlib | edit the Figure’s artists, then .widget.draw_idle() |
| plotnine | the same matplotlib-artist technique, reached through .widget |
| bqplot | mutate a mark’s or scale’s traits on the existing Figure |
| holoviews | drive a DynamicMap with hv.streams.Pipe/Params |
| geoviews | the same hv.streams.Pipe mechanism as holoviews |
| datashader | hv.streams.Params driving rasterize()’s aggregator/filter |
| jscatter | a method on plot.value or a trait on .widget, under reactive.isolate() |
| obsplot | assign a new spec to .widget.spec |
| panel | mutate .value (the live Panel object) directly |
| ipyleaflet | mutate .widget’s layers/traits (style, visibility, basemap) |
| leafmap | the same ipyleaflet-backed trait writes |
| localtileserver | map.widget.layers[...].url/.opacity – the ipyleaflet pattern |
| maplibre | .widget methods: set_paint_property, set_filter, set_data, add_layer |
| lonboard | layer.<trait> = ... to restyle, or map.widget.fly_to(...) to move the camera |
| geolibre | project-mutation methods: add_geojson(), set_center(), layer.set_style() |
| keplergl | .widget.data = ... (roughly 1,500x smaller than re-sending the full bundle) |
| itables | table.widget.selected_rows = [...] |
| reactable | tbl.widget.props = {**w.props, ...} |
| ipydatagrid | .transform(), .set_cell_value*(), or assigning .selections |
| perspective | .widget.update(), .widget.theme = ... |
| mosaic | assign a new .widget.spec (resets the current selection) |
| quak | layout only (.layout.height) – the sql trait is read-only from Python |
| pygwalker | none – no synced trait; carry a saved chart spec forward across a re-render instead |
| embedding-atlas | none – ignores Python trait writes; round-trip _state across a re-render instead |
| k3d | .widget.objects[i].<trait> |
| pythreejs | any trait on any object reachable from scene.widget |
| ipyvolume | plot.widget.<trait> |
| pyvista | none – .widget is a dead HTML snapshot; every change is a re-render |
| mols2grid | grid.grid.filter(mask) |
| nglview | .widget.add_representation(...), .widget.frame = ..., .widget.clear_representations() |
| vitessce | edit a deep copy of .widget._config, bump uid, reassign it |
| vizarr | .widget.add_image() (add-only – no remove or replace) |
| niivue | .widget.set_*() methods and writable traits (volumes[i].cal_min, scene.crosshair_pos) |
| ipyaladin | sky.widget.<trait> = ... |
| rerun | the high-level Viewer wrapper’s update_panels()/set_time_ctrl() |
| ipycytoscape | network.widget.<trait> = ... |
| ipysigma | none – writes reach the browser model but nothing redraws; re-render, carrying camera_state forward |
| drawdata | ScatterWidget: canvas.widget.data = [...]; BarWidget: none, re-render only |
| ipycanvas | draw directly on .widget from an effect |
| ipyfilechooser | .widget.show_hidden, .widget.filter_pattern, .widget.reset(...) |
| render_widget (any anywidget) | the same pattern, generically: .widget.<trait> = ... from an effect |
A handful of widgets have no in-place path at all (pygwalker, embedding-atlas, pyvista, ipysigma): their browser code either never listens for Python-driven trait changes, or has nothing left to mutate once rendered. For those, re-rendering is the only option, and each guide’s section above explains what state (if any) is worth carrying forward into the next render to soften the reset.
When in-place doesn’t save bytes
Not every trait write is cheap, and assuming otherwise is the one place this pattern bites back. The performance article’s measured comparison has the numbers: on the same 819 KB GeoJSON choropleth, toggling ipyleaflet’s layer.visible costs 444 bytes, but setting layer.style costs 1.09 MB – almost exactly what a full re-render costs. The reason is upstream, not a shinywidgets limitation: ipyleaflet’s GeoJSON rebuilds its entire data trait from scratch whenever style changes, so any style change re-syncs the whole feature collection, not a small style object. The lesson generalizes past this one widget: “in-place” means “only the changed trait crosses the wire” only when the trait you’re setting is actually small on the wire. Verify what a given trait write actually costs (the performance article’s methodology section shows how) before assuming in-place is automatically the cheap path.
Lifetimes
A widget built directly inside a render function lives exactly as long as that render: the next time the function runs, the old widget is closed and a new one takes its place. Two other lifetimes exist for the cases that need to outlive a single render:
session_lifetime(): build the widget once per session, and keep returning that same instance from later renders (e.g., cache it in a@reactive.calc, which only recomputes if something it reads changes). Use this when a render would otherwise rebuild a widget the user is actively interacting with – the cached instance keeps its zoom, selection, and everything else.shared_lifetime()(experimental – its own docstring warns that not every widget package behaves well mirrored into several browsers, and its behavior may change in a future release): build the widget once for the whole process, before – or outside – any session, and show it to every session that connects. Every user sees (and can change) the same object; it is never freed.
examples/03_lifetimes.py renders one map each way, plus a “Re-render” button for the first two:
# Three lifetimes for a widget:
#
# - Default (render-scoped): a widget built directly in the render function
# is closed and replaced every time that function runs. "Re-render" here
# rebuilds the map from scratch, so any zoom/pan the user did is gone.
# - session_lifetime(): build the map ONCE per session (cached in a
# `@reactive.calc`, which only recomputes if something it reads changes),
# and keep returning that same instance. "Re-render" redisplays the same
# Map, so the user's zoom/pan survives.
# - shared_lifetime(): built once, before any session exists, and shown to
# every session. One user's pan/zoom changes it for everyone -- open this
# app in two tabs and zoom one to see the other move.
#
# THE_SHARED_MAP lives in the sibling `globals.py`, not here. Express re-runs
# THIS file's body for every session (see globals.py's comment); a
# `shared_lifetime()` widget constructed directly in it would be rebuilt --
# and re-shown as a fresh, independent map -- on every new session, which
# defeats the whole point (each session would silently get its own map,
# never mirroring another session's interaction).
from globals import THE_SHARED_MAP
from ipyleaflet import Map
from shiny import reactive
from shiny.express import input, render, ui
from shinywidgets import render_ipyleaflet, session_lifetime
ui.page_opts(title="Lifetimes", fillable=True)
ui.input_action_button("rerender", "Re-render (default + cached)")
with ui.layout_columns():
with ui.card(full_screen=True):
ui.card_header("Default lifetime: zoom resets on re-render")
@render_ipyleaflet
@reactive.event(input.rerender, ignore_none=False)
def default_map():
return Map(center=(45.5, -122.6), zoom=9)
with ui.card(full_screen=True):
ui.card_header("session_lifetime(): zoom survives re-render")
@reactive.calc
def cached_map_calc():
with session_lifetime():
return Map(center=(45.5, -122.6), zoom=9)
@render_ipyleaflet
@reactive.event(input.rerender, ignore_none=False)
def cached_map():
return cached_map_calc()
with ui.card(full_screen=True):
ui.card_header("shared_lifetime(): every session shows (and can move) this map")
@render_ipyleaflet
def shared_map():
return THE_SHARED_MAPZoom in on both, then click “Re-render”: the default-lifetime map snaps back to its construction-time view; the session_lifetime() one doesn’t move, because the button’s click just redisplays the exact same Python object:

Where to go next
- Reacting to widgets for the accessor mental model this article’s “Writing back” examples read from (
map.clicked()), re-fire and clearing semantics, and rate limiting. - The performance article for the measured bytes behind every claim here: re-render vs. in-place, what a trait write actually costs, and what
session_lifetime()/shared_lifetime()cost in server memory as concurrent sessions pile up. - Each widget’s own guide’s Updating the widget from Python section (linked from the table above) for a verified, runnable example specific to that widget.

