|
| 1 | +```python exec |
| 2 | +import reflex as rx |
| 3 | +from pcweb.templates.docpage import definition |
| 4 | +``` |
| 5 | + |
| 6 | +# Shared State |
| 7 | + |
| 8 | +_New in version 0.8.23_. |
| 9 | + |
| 10 | +Defining a subclass of `rx.SharedState` creates a special type of state that may be shared by multiple clients. Shared State is useful for creating real-time collaborative applications where multiple users need to see and interact with the same data simultaneously. |
| 11 | + |
| 12 | +## Using SharedState |
| 13 | + |
| 14 | +An `rx.SharedState` subclass behaves similarly to a normal `rx.State` subclass and will be private to each client until it is explicitly linked to a given token. Once linked, any changes made to the Shared State by one client will be propagated to all other clients sharing the same token. |
| 15 | + |
| 16 | +```md alert info |
| 17 | +# What should be used as a token? |
| 18 | + |
| 19 | +A token can be any string that uniquely identifies a group of clients that should share the same state. Common choices include room IDs, document IDs, or user group IDs. Ensure that the token is securely generated and managed to prevent unauthorized access to shared state. |
| 20 | +``` |
| 21 | + |
| 22 | +```md alert warning |
| 23 | +# Linked token cannot contain underscore (_) characters. |
| 24 | + |
| 25 | +Underscore characters are currently used as an internal delimiter for tokens and will raise an exception if used for linked states. |
| 26 | + |
| 27 | +This is a temporary restriction and will be removed in a future release. |
| 28 | +``` |
| 29 | + |
| 30 | +### Linking Shared State |
| 31 | + |
| 32 | +An `rx.SharedState` subclass can be linked to a token using the `_link_to` method, which is async and returns the linked state instance. After linking, subsequent events triggered against the shared state will be executed in the context of the linked state. To unlink from the token, return the result of awaiting the `_unlink` method. |
| 33 | + |
| 34 | +To try out the collaborative counter example, open this page in a second or third browser tab and click the "Link" button. You should see the count increment in all tabs when you click the "Increment" button in any of them. |
| 35 | + |
| 36 | +```python demo exec |
| 37 | +class CollaborativeCounter(rx.SharedState): |
| 38 | + count: int = 0 |
| 39 | + |
| 40 | + @rx.event |
| 41 | + async def toggle_link(self): |
| 42 | + if self._linked_to: |
| 43 | + return await self._unlink() |
| 44 | + else: |
| 45 | + linked_state = await self._link_to("shared-global-counter") |
| 46 | + linked_state.count += 1 # Increment count on link |
| 47 | + |
| 48 | + @rx.var |
| 49 | + def is_linked(self) -> bool: |
| 50 | + return bool(self._linked_to) |
| 51 | + |
| 52 | +def shared_state_example(): |
| 53 | + return rx.vstack( |
| 54 | + rx.text(f"Collaborative Count: {CollaborativeCounter.count}"), |
| 55 | + rx.cond( |
| 56 | + CollaborativeCounter.is_linked, |
| 57 | + rx.button("Unlink", on_click=CollaborativeCounter.toggle_link), |
| 58 | + rx.button("Link", on_click=CollaborativeCounter.toggle_link), |
| 59 | + ), |
| 60 | + rx.button("Increment", on_click=CollaborativeCounter.set_count(CollaborativeCounter.count + 1)), |
| 61 | + ) |
| 62 | +``` |
| 63 | + |
| 64 | +```md alert info |
| 65 | +# Computed vars may reference SharedState |
| 66 | + |
| 67 | +Computed vars in other states may reference shared state data using `get_state`, just like private states. This allows private states to provide personalized views of shared data. |
| 68 | + |
| 69 | +Whenever the shared state is updated, any computed vars depending on it will be re-evaluated in the context of each client's private state. |
| 70 | +``` |
| 71 | + |
| 72 | +### Identifying Clients |
| 73 | + |
| 74 | +Each client linked to a shared state can be uniquely identified by their `self.router.session.client_token`. Shared state events should _never_ rely on identifiers passed in as parameters, as these can be spoofed from the client. Instead, always use the `client_token` to identify the client triggering the event. |
| 75 | + |
| 76 | +```python demo exec |
| 77 | +import uuid |
| 78 | + |
| 79 | +class SharedRoom(rx.SharedState): |
| 80 | + shared_room: str = rx.LocalStorage() |
| 81 | + _users: dict[str, str] = {} |
| 82 | + |
| 83 | + @rx.var |
| 84 | + def user_list(self) -> str: |
| 85 | + return ", ".join(self._users.values()) |
| 86 | + |
| 87 | + @rx.event |
| 88 | + async def join(self, username: str): |
| 89 | + if not self.shared_room: |
| 90 | + self.shared_room = f"shared-room-{uuid.uuid4()}" |
| 91 | + linked_state = await self._link_to(self.shared_room) |
| 92 | + linked_state._users[self.router.session.client_token] = username |
| 93 | + |
| 94 | + @rx.event |
| 95 | + async def leave(self): |
| 96 | + if self._linked_to: |
| 97 | + return await self._unlink() |
| 98 | + |
| 99 | + |
| 100 | +class PrivateState(rx.State): |
| 101 | + @rx.event |
| 102 | + def handle_submit(self, form_data: dict): |
| 103 | + return SharedRoom.join(form_data["username"]) |
| 104 | + |
| 105 | + @rx.var |
| 106 | + async def user_in_room(self) -> bool: |
| 107 | + shared_state = await self.get_state(SharedRoom) |
| 108 | + return self.router.session.client_token in shared_state._users |
| 109 | + |
| 110 | + |
| 111 | +def shared_room_example(): |
| 112 | + return rx.vstack( |
| 113 | + rx.text("Shared Room"), |
| 114 | + rx.text(f"Users: {SharedRoom.user_list}"), |
| 115 | + rx.cond( |
| 116 | + PrivateState.user_in_room, |
| 117 | + rx.button("Leave Room", on_click=SharedRoom.leave), |
| 118 | + rx.form( |
| 119 | + rx.input(placeholder="Enter your name", name="username"), |
| 120 | + rx.button("Join Room"), |
| 121 | + on_submit=PrivateState.handle_submit, |
| 122 | + ), |
| 123 | + ), |
| 124 | + ) |
| 125 | +``` |
| 126 | + |
| 127 | +```md alert warning |
| 128 | +# Store sensitive data in backend-only vars with an underscore prefix |
| 129 | + |
| 130 | +Shared State data is synchronized to all linked clients, so avoid storing sensitive information (e.g., client_tokens, user credentials, personal data) in frontend vars, which would expose them to all users and allow them to be modified outside of explicit event handlers. Instead, use backend-only vars (prefixed with an underscore) to keep sensitive data secure on the server side and provide controlled access through event handlers and computed vars. |
| 131 | +``` |
| 132 | + |
| 133 | +### Introspecting Linked Clients |
| 134 | + |
| 135 | +An `rx.SharedState` subclass has two attributes for determining link status and peers, which are updated during linking and unlinking, and come with some caveats. |
| 136 | + |
| 137 | +**`_linked_to: str`** |
| 138 | + |
| 139 | +Provides the token that the state is currently linked to, or empty string if not linked. |
| 140 | + |
| 141 | +This attribute is only set on the linked state instance returned by `_link_to`. It will be an empty string on any unlinked shared state instances. However, if another state links to a client's private token, then the `_linked_to` attribute will be set to the client's token rather than an empty string. |
| 142 | + |
| 143 | +When `_linked_to` equals `self.router.session.client_token`, it is assumed that the current client is unlinked, but another client has linked to this client's private state. Although this is possible, it is generally discouraged to link shared states to private client tokens. |
| 144 | + |
| 145 | +**`_linked_from: set[str]`** |
| 146 | + |
| 147 | +A set of client tokens that are currently linked to this shared state instance. |
| 148 | + |
| 149 | +This attribute is only updated during `_link_to` and `_unlink` calls. In situations where unlinking occurs otherwise, such as client disconnects, `self.reset()` is called, or state expires on the backend, `_linked_from` may contain stale client tokens that are no longer linked. These can be cleaned periodically by checking if the tokens still exist in `app.event_namespace.token_to_sid`. |
| 150 | + |
| 151 | +## Guidelines and Best Practices |
| 152 | + |
| 153 | +### Keep Shared State Minimal |
| 154 | + |
| 155 | +When defining a shared state, aim to keep it as minimal as possible. Only include the data and methods that need to be shared between clients. This helps reduce complexity and potential synchronization issues. |
| 156 | + |
| 157 | +Linked states are always loaded into the tree for each event on each linked client and large states take longer to serialize and transmit over the network. Because linked states are regularly loaded in the context of many clients, they incur higher lock contention, so minimizing loading time also reduces lock waiting time for other clients. |
| 158 | + |
| 159 | +### Prefer Backend-Only Vars in Shared State |
| 160 | + |
| 161 | +A shared state should primarily use backend-only vars (prefixed with an underscore) to store shared data. Often, not all users of the shared state need visibility into all of the data in the shared state. Use computed vars to provide sanitized access to shared data as needed. |
| 162 | + |
| 163 | +```python |
| 164 | +from typing import Literal |
| 165 | + |
| 166 | +class SharedGameState(rx.SharedState): |
| 167 | + # Sensitive user metadata stored in backend-only variable. |
| 168 | + _players: dict[str, Literal["X", "O"]] = {} |
| 169 | + |
| 170 | + @rx.event |
| 171 | + def make_move(self, x: int, y: int): |
| 172 | + # Identify users by client_token, never by arguments passed to the event. |
| 173 | + player_token = self.router.session.client_token |
| 174 | + player_piece = self._players.get(player_token) |
| 175 | +``` |
| 176 | + |
| 177 | +```md alert warning |
| 178 | +# Do Not Trust Event Handler Arguments |
| 179 | + |
| 180 | +The client can send whatever data it wants to event handlers, so never rely on arguments passed to event handlers for sensitive information such as user identity or permissions. Always use secure identifiers like `self.router.session.client_token` to identify the client triggering the event. |
| 181 | +``` |
| 182 | + |
| 183 | +### Expose Per-User Data via Private States |
| 184 | + |
| 185 | +If certain data in the shared state needs to be personalized for each user, prefer to expose that data through computed vars defined in private states. This allows each user to have their own view of the shared data without exposing sensitive information to other users. It also reduces the amount of unrelated data sent to each client and improves caching performance by keeping each user's view cached in their own private state, rather than always recomputing the shared state vars for each user that needs to have their information updated. |
| 186 | + |
| 187 | +Use async computed vars with `get_state` to access shared state data from private states. |
| 188 | + |
| 189 | +```python |
| 190 | +class UserGameState(rx.State): |
| 191 | + @rx.var |
| 192 | + async def player_piece(self) -> str | None: |
| 193 | + shared_state = await self.get_state(SharedGameState) |
| 194 | + return shared_state._players.get(self.router.session.client_token) |
| 195 | +``` |
| 196 | + |
| 197 | +### Use Dynamic Routes for Linked Tokens |
| 198 | + |
| 199 | +It is often convenient to define dynamic routes that include the linked token as part of the URL path. This allows users to easily share links to specific shared state instances. The dynamic route can use `on_load` to link the shared state to the token extracted from the URL. |
| 200 | + |
| 201 | +```python |
| 202 | +class SharedRoom(rx.SharedState): |
| 203 | + async def on_load(self): |
| 204 | + # `self.room_id` is the automatically defined dynamic route var. |
| 205 | + await self._link_to(self.room_id.replace("_", "-") or "default-room") |
| 206 | + |
| 207 | + |
| 208 | +def room_page(): ... |
| 209 | + |
| 210 | + |
| 211 | +app.add_route( |
| 212 | + room_page, |
| 213 | + path="/room/[room_id]", |
| 214 | + on_load=SharedRoom.on_load, |
| 215 | +) |
| 216 | +``` |
0 commit comments