Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
edbbdfd
Add persistent session state settings
Archmonger Sep 14, 2026
1a4beca
Add SessionStateModel for persistent session state
Archmonger Sep 14, 2026
978436c
Add SessionStateModel migration
Archmonger Sep 14, 2026
447427c
Add SessionState type
Archmonger Sep 14, 2026
a646707
Restore types.py and add SessionState type
Archmonger Sep 14, 2026
3a72b7d
Add SessionState type used by use_session_state
Archmonger Sep 14, 2026
ed4040b
Revert types.py to original
Archmonger Sep 14, 2026
962ce32
Add use_session_state persistent state hook
Archmonger Sep 14, 2026
742e67a
Add session state cleaning to clean task
Archmonger Sep 14, 2026
1fe926b
Add --session-state flag to clean_reactpy command
Archmonger Sep 14, 2026
5f14fbc
Add session state database tests
Archmonger Sep 14, 2026
404ca18
Use explicit constraint/index names in SessionStateModel
Archmonger Sep 14, 2026
dde53da
Restore models.py with explicit constraint names in SessionStateModel
Archmonger Sep 14, 2026
3dfe9d3
Match migration constraint/index names to model
Archmonger Sep 14, 2026
bd050cd
Add persistent session state feature to changelog
Archmonger Sep 14, 2026
902a18b
Restore full changelog with session state entries
Archmonger Sep 14, 2026
036cf6c
Add ruff noqa for mutable Meta class attributes
Archmonger Sep 14, 2026
8a106e1
Fix use_session_state race conditions and scope handling
Archmonger Sep 14, 2026
161cdc0
Restore full hooks.py with fixed use_session_state
Archmonger Sep 14, 2026
788ee98
Fix session state test to await async helpers and correct cleanup timing
Archmonger Sep 14, 2026
c5cfe0c
Type the flush_task ref to satisfy pyright
Archmonger Sep 14, 2026
da09eb2
Restore full hooks.py with typed flush_task ref
Archmonger Sep 14, 2026
c09d90a
Add system checks for session state settings
Sep 15, 2026
7865e1c
Document session state settings and use_session_state hook
Sep 15, 2026
e4eb180
Fix docs spellcheck for session state terms
Sep 15, 2026
6d26990
Reduce session state sync interval to 10s and allow disabling periodi…
Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ Don't forget to remove deprecated code on each major release!

### Added

- Add `reactpy_django.hooks.use_session_state` hook for persistent state across WebSocket reconnects.
- State is stored in the ReactPy database, so it survives multi-process deployments and round-robin load balancing across hosts.
- `settings.py:REACTPY_SESSION_STATE_MODE` to control whether state is scoped per-tab (default) or per-user.
- `settings.py:REACTPY_SESSION_STATE_SYNC_INTERVAL` (default 10 seconds) to control how frequently state is flushed to the database. Set to `0` to disable periodic syncing so state is only persisted on unmount.
- `settings.py:REACTPY_SESSION_STATE_MAX_AGE` to control how long stale session state is retained.
- `settings.py:REACTPY_CLEAN_SESSION_STATE` to control whether stale session state is cleaned up during automatic cleanups.
- Automatically serve ReactPy wheel from Django's static directory when using PyScript.

### Changed
Expand Down Expand Up @@ -444,7 +450,7 @@ Don't forget to remove deprecated code on each major release!
### Fixed

- Change type hint on `view_to_component` callable to have `request` argument be optional.
- Change type hint on `view_to_component` to represent it as a decorator with parenthesis (such as `@view_to_component(compatibility=True)`)
- Change type hint on `view_to_component` to represent it as a decorator with parenthesis (such as `@view_to_component(compatibility=True)`).

### Security

Expand Down
13 changes: 13 additions & 0 deletions docs/examples/python/use_session_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from reactpy import component, html

from reactpy_django.hooks import use_session_state


@component
def my_component():
count, set_count = use_session_state(0, key="counter")

return html.button(
{"onClick": lambda _: set_count(count + 1)},
f"Count: {count}",
)
3 changes: 3 additions & 0 deletions docs/src/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ backends
backhaul
broadcasted
changelog
debounced
django
frontend
frontends
Expand All @@ -18,6 +19,7 @@ misconfiguration
misconfigurations
my_template
nox
picklable
plotly
postfixed
postprocessing
Expand All @@ -39,6 +41,7 @@ serializable
stylesheet
stylesheets
sublicense
unmount
unstyled
WebSocket
WebSockets
Expand Down
41 changes: 41 additions & 0 deletions docs/src/reference/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,47 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`.

---

### Use Session State

Persist state across WebSocket reconnects (and, optionally, page reloads) so that it survives a fresh `#!python Layout` being created on reconnect.

This hook stores its value in the `#!python REACTPY_DATABASE`, so it is more robust than in-memory state because it survives multi-process deployments and round-robin load balancing across multiple hosts.

=== "components.py"

```python
{% include "../../examples/python/use_session_state.py" %}
```

??? example "See Interface"

<font size="4">**Parameters**</font>

| Name | Type | Description | Default |
| --- | --- | --- | --- |
| `#!python default` | `#!python Any` | The value to use when no persisted state exists. | N/A |
| `#!python key` | `#!python str` | A unique identifier for this state slot within the computed scope. Multiple `#!python use_session_state` hooks in the same component must use distinct keys. | N/A |
| `#!python save_default` | `#!python bool` | If `#!python True`, the `#!python default` value will be persisted when no state already exists in the database. | `#!python False` |

<font size="4">**Returns**</font>

| Type | Description |
| --- | --- |
| `#!python tuple[Any, Callable[[Any], None]]` | A tuple of `#!python (state, set_state)`. `#!python state` is the current value (loaded from the database, or `#!python default` if none exists). `#!python set_state` updates the in-memory value immediately and schedules a debounced database write so that frequently-changing values do not hammer the database. The update interval is controlled by `#!python REACTPY_SESSION_STATE_SYNC_INTERVAL`; setting it to `#!python 0` disables periodic syncing so writes only occur on unmount. |

??? question "How is state scoped?"

The state's scope is controlled by the `#!python REACTPY_SESSION_STATE_MODE` [setting](./settings.md#reactpy_session_state_mode).

- `#!python "tab"` (default): state is scoped to the rendered component (a per-tab, per-component token that is stable across reconnects). This works for anonymous users without requiring `#!python django.contrib.sessions`, and isolates state between browser tabs.
- `#!python "user"`: state is scoped to the authenticated user, falling back to a per-tab token for anonymous users.

??? warning "Only serializable data may be stored"

Values are serialized with `#!python dill`, so most common Python objects are supported, but objects holding resources that are not picklable (e.g. open file handles or network connections) will fail.

---

## Communication Hooks

---
Expand Down
53 changes: 53 additions & 0 deletions docs/src/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,47 @@ You can use the `#!python prerender` argument in your [template tag](./template-

---

## Session State Settings

---

### `#!python REACTPY_SESSION_STATE_MODE`

**Default:** `#!python "tab"`

**Example Value(s):** `#!python "tab"`, `#!python "user"`

Controls the scope of state persisted by the [`use_session_state`](./hooks.md#use-session-state) hook.

- `#!python "tab"` (default): state is scoped to the rendered component (a per-tab, per-component token that is stable across WebSocket reconnects). This works for anonymous users without requiring `#!python django.contrib.sessions`, and isolates state between browser tabs.
- `#!python "user"`: state is scoped to the authenticated user, falling back to a per-tab token for anonymous users.

---

### `#!python REACTPY_SESSION_STATE_SYNC_INTERVAL`

**Default:** `#!python 10`

**Example Value(s):** `#!python 0`, `#!python 1`, `#!python 30`, `#!python 60`

Seconds between debounced database flush writes for `#!python use_session_state`. Rapid state changes (e.g. typing) are coalesced into a single database write after this interval elapses.

Set this value to `#!python 0` to disable periodic syncing. In that case, state is only persisted to the database when the component is unmounted (such as when a WebSocket reconnects).

---

### `#!python REACTPY_SESSION_STATE_MAX_AGE`

**Default:** `#!python 259200`

**Example Value(s):** `#!python 0`, `#!python 3600`, `#!python 604800`

Maximum seconds stale session state is retained before it is removed during [ReactPy clean up](#auto-clean-settings).

Use `#!python 0` to immediately expire stale session state.

---

## Stability Settings

---
Expand Down Expand Up @@ -303,3 +344,15 @@ Configures whether ReactPy should clean up expired authentication tokens during
Configures whether ReactPy should clean up orphaned user data during automatic clean up operations.

Typically, user data does not become orphaned unless the server crashes during a `#!python User` delete operation.

---

### `#!python REACTPY_CLEAN_SESSION_STATE`

**Default:** `#!python True`

**Example Value(s):** `#!python False`

Configures whether ReactPy should clean up stale session state during automatic clean up operations.

Stale session state is state that has not been updated within `#!python REACTPY_SESSION_STATE_MAX_AGE` seconds.
71 changes: 71 additions & 0 deletions src/reactpy_django/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,75 @@ def reactpy_errors(app_configs, **kwargs):
)
)

# Check if REACTPY_SESSION_STATE_MODE is a valid data type
if not isinstance(config.REACTPY_SESSION_STATE_MODE, str):
errors.append(
checks.Error(
"Invalid type for REACTPY_SESSION_STATE_MODE.",
hint="REACTPY_SESSION_STATE_MODE should be a string.",
id="reactpy_django.E030",
)
)

# Check if REACTPY_SESSION_STATE_MODE is a valid value
if config.REACTPY_SESSION_STATE_MODE not in ("tab", "user"):
errors.append(
checks.Error(
"Invalid value for REACTPY_SESSION_STATE_MODE.",
hint="REACTPY_SESSION_STATE_MODE should be either 'tab' or 'user'.",
obj=config.REACTPY_SESSION_STATE_MODE,
id="reactpy_django.E031",
)
)

# Check if REACTPY_SESSION_STATE_SYNC_INTERVAL is a valid data type
if not isinstance(config.REACTPY_SESSION_STATE_SYNC_INTERVAL, int):
errors.append(
checks.Error(
"Invalid type for REACTPY_SESSION_STATE_SYNC_INTERVAL.",
hint="REACTPY_SESSION_STATE_SYNC_INTERVAL should be an integer.",
id="reactpy_django.E032",
)
)

# Check if REACTPY_SESSION_STATE_SYNC_INTERVAL is a non-negative integer
if isinstance(config.REACTPY_SESSION_STATE_SYNC_INTERVAL, int) and config.REACTPY_SESSION_STATE_SYNC_INTERVAL < 0:
errors.append(
checks.Error(
"Invalid value for REACTPY_SESSION_STATE_SYNC_INTERVAL.",
hint="REACTPY_SESSION_STATE_SYNC_INTERVAL should be a non-negative integer. Use 0 to disable periodic syncing.",
id="reactpy_django.E033",
)
)

# Check if REACTPY_SESSION_STATE_MAX_AGE is a valid data type
if not isinstance(config.REACTPY_SESSION_STATE_MAX_AGE, int):
errors.append(
checks.Error(
"Invalid type for REACTPY_SESSION_STATE_MAX_AGE.",
hint="REACTPY_SESSION_STATE_MAX_AGE should be an integer.",
id="reactpy_django.E034",
)
)

# Check if REACTPY_SESSION_STATE_MAX_AGE is a positive integer
if isinstance(config.REACTPY_SESSION_STATE_MAX_AGE, int) and config.REACTPY_SESSION_STATE_MAX_AGE < 0:
errors.append(
checks.Error(
"Invalid value for REACTPY_SESSION_STATE_MAX_AGE.",
hint="REACTPY_SESSION_STATE_MAX_AGE should be a positive integer.",
id="reactpy_django.E035",
)
)

# Check if REACTPY_CLEAN_SESSION_STATE is a valid data type
if not isinstance(config.REACTPY_CLEAN_SESSION_STATE, bool):
errors.append(
checks.Error(
"Invalid type for REACTPY_CLEAN_SESSION_STATE.",
hint="REACTPY_CLEAN_SESSION_STATE should be a boolean.",
id="reactpy_django.E036",
)
)

return errors
23 changes: 22 additions & 1 deletion src/reactpy_django/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from itertools import cycle
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING, Callable, Literal

from django.conf import settings
from django.core.cache import DEFAULT_CACHE_ALIAS
Expand Down Expand Up @@ -136,6 +136,27 @@
"REACTPY_CLEAN_USER_DATA",
True,
)
REACTPY_CLEAN_SESSION_STATE: bool = getattr(
settings,
"REACTPY_CLEAN_SESSION_STATE",
True,
)
SessionStateMode = Literal["tab", "user"]
REACTPY_SESSION_STATE_MODE: SessionStateMode = getattr(
settings,
"REACTPY_SESSION_STATE_MODE",
"tab", # Default to per-tab (per-component token) scope
)
REACTPY_SESSION_STATE_SYNC_INTERVAL: int = getattr(
settings,
"REACTPY_SESSION_STATE_SYNC_INTERVAL",
10, # Default to 10 seconds; set to 0 to disable periodic syncing
)
REACTPY_SESSION_STATE_MAX_AGE: int = getattr(
settings,
"REACTPY_SESSION_STATE_MAX_AGE",
259200, # Default to 3 days
)
REACTPY_DEFAULT_FORM_TEMPLATE: str | None = getattr(
settings,
"REACTPY_DEFAULT_FORM_TEMPLATE",
Expand Down
Loading
Loading