From e3cbd8841b47b6137c4c675702ec38c09d3cc463 Mon Sep 17 00:00:00 2001 From: "Maarten A. Breddels" Date: Tue, 10 Sep 2024 11:44:55 +0200 Subject: [PATCH] docs: update state management documentation --- .../content/05-fundamentals/10-components.md | 33 +-- .../05-fundamentals/50-state-management.md | 220 ++++++++++++++++-- 2 files changed, 224 insertions(+), 29 deletions(-) diff --git a/solara/website/pages/documentation/getting_started/content/05-fundamentals/10-components.md b/solara/website/pages/documentation/getting_started/content/05-fundamentals/10-components.md index 6f09ff57d..b3c07f9d9 100644 --- a/solara/website/pages/documentation/getting_started/content/05-fundamentals/10-components.md +++ b/solara/website/pages/documentation/getting_started/content/05-fundamentals/10-components.md @@ -41,7 +41,7 @@ By utilizing both Widget Components and Function Components, you can create flex In Solara, users can create their own custom components without any special distinction from the built-in components provided by the framework, they are all components. These user-defined components have the same capabilities and can be composed seamlessly alongside Solara's components, allowing for the creation of highly customized and reusable user interfaces. # Defining Components -To create a component in Solara, you'll start by defining a Python function decorated with @solara.component. Inside the function, you can create the component's structure by calling Solara's built-in components or creating custom components to suit your specific needs. If a single element is created, it's taken as the component's main element. If multiple elements are created, they are automatically wrapped in a Column component. +To create a component in Solara, you'll start by defining a Python function decorated with `@solara.component`. Inside the function, you can create the component's structure by calling Solara's built-in components or creating custom components to suit your specific needs. If a single element is created, it's taken as the component's main element. If multiple elements are created, they are automatically wrapped in a [Column component](/documentation/components/layout/column). Here's an example of a simple Solara component that displays a button: @@ -69,7 +69,7 @@ def MyApp(): MyButton() ``` -In this example, we create a `MyApp` function decorated with `@solara.component`. The function create two MyButton elements, resulting in two buttons (or two component instances). +In this example, we create a `MyApp` function decorated with `@solara.component`. The function creates two MyButton elements, resulting in two buttons (or two component instances). ## Handling User Interactions Components in Solara can capture user input and respond to events, such as button clicks or form submissions. To handle user interactions, you'll define callback functions and connect them to your components using Solara's event handling system. @@ -87,7 +87,7 @@ def MyInteractiveButton(): solara.Button("Click me!", on_click=on_button_click) ``` -In this example, we define a function called on_button_click that will be executed when the button is clicked. In the MyInteractiveButton function, we create a Button component and set the on_click argument to the on_button_click function. +In this example, we define a function called `on_button_click` that will be executed when the button is clicked. In the `MyInteractiveButton` function, we create a Button component and set the `on_click` argument to the `on_button_click` function. By following these steps, you can create and use components to build rich, interactive applications with Solara. @@ -111,7 +111,7 @@ def MyButton(text): In this example, we define a function called MyButton that takes a single argument, text. The render function creates a Button component from Solara with the specified text. ### Using Application state in Components -To manage the state of a component in Solara, you can use the solara.reactive() function to create reactive variables. Reactive variables are used to store values that can change over time and automatically trigger component updates when their values change. This allows you to create components that respond to changes in data and user interactions. +To manage the state of a component in Solara, you can use the [`solara.reactive()`](/documentation/api/utilities/reactive) function to create reactive variables. Reactive variables are used to store values that can change over time and automatically trigger component updates when their values change. This allows you to create components that respond to changes in data and user interactions. Here's an example that demonstrates the use of reactive variables in Solara components: ```solara @@ -119,22 +119,25 @@ import solara counter = solara.reactive(0) + def increment(): counter.value += 1 + @solara.component def CounterDisplay(): solara.Info(f"Counter: {counter.value}") + @solara.component def IncrementButton(): solara.Button("Increment", on_click=increment) + @solara.component def Page(): IncrementButton() CounterDisplay() - ``` In this example, we create a reactive variable counter with an initial value of 0. We define two components: `CounterDisplay` and `IncrementButton`. `CounterDisplay` renders the current value of counter, while `IncrementButton` increments the value of counter when clicked. Whenever the counter value changes, `CounterDisplay` automatically updates to display the new value. @@ -143,33 +146,35 @@ By using arguments and state in your Solara components, you can create more dyna ### Internal State in Components -In addition to using reactive variables for global or application-wide state, you can also manage internal or component-specific state using the use_state hook in Solara. The use_state hook allows you to define state variables that are local to a component, and automatically trigger updates when their values change. +In addition to using reactive variables for global or application-wide state, you can also manage internal or component-specific state using the [`use_reactive`](/documentation/api/hooks/use_reactive) hook in Solara. The `use_reactive` hook allows you to define reactive variables which are local to a component, and automatically trigger updates when their values change. -To use the use_state hook, call the solara.use_state() function inside your component function. This function takes an initial value as an argument and returns a tuple containing the current state value and a function to update the state. +To use the `use_reactive` hook, call the solara.use_reactive() function inside your component function. This function takes an initial value as an argument and returns a the same reactive variable on each render. -Here's an example that demonstrates the use of the use_state hook to manage internal state in a Solara component: +Here's an example that demonstrates the use of the use_reactive hook to manage internal state in a Solara component: ```solara import solara + @solara.component def Counter(): - count, set_count = solara.use_state(0) + count = solara.use_reactive(0) def increment(): - set_count(count + 1) + count.value += 1 solara.Button("Increment", on_click=increment) - solara.Info(f"Counter: {count}") + solara.Info(f"Counter: {count.value}") + @solara.component def Page(): Counter() ``` -In this example, we define a Counter component that uses the use_state hook to manage its internal state. We create a state variable count with an initial value of 0 and a function set_count to update the state. The increment function increments the value of count when the button is clicked. Whenever the count value changes, the component automatically updates to display the new value. +In this example, we define a Counter component that uses the `use_reactive` hook to manage its internal state. We create a reactive variable called `count` with an initial value of 0 and a function `increment` to update the state. The increment function increments the value of count when the button is clicked. Whenever the count value changes, the component automatically updates to display the new value. -By using the use_state hook, you can manage the internal state of your components and create more dynamic and interactive applications that respond to user input and changes in data. +By using the `use_reactive` hook, you can manage the internal state of your components and create more dynamic and interactive applications that respond to user input and changes in data. ## Lazy Rendering in Solara Components @@ -216,7 +221,7 @@ This example demonstrates Solara's lazy rendering, where only the relevant compo ## Conclusions -In conclusion, understanding components, their arguments, and how to manage their internal state is crucial for building Solara applications. To create more advanced components, you need to have a deeper understanding of hooks, such as the use_state hook we have already discussed. +In conclusion, understanding components, their arguments, and how to manage their internal state is crucial for building Solara applications. To create more advanced components, you need to have a deeper understanding of hooks, such as the `use_reactive` hook we have already discussed. In the next fundamentals article, we will explore more hooks available in Solara, which will enable you to build more sophisticated components that cater to a wide range of use cases. By learning about hooks, you can create powerful components that can manage state, interact with other components, and respond to user input. diff --git a/solara/website/pages/documentation/getting_started/content/05-fundamentals/50-state-management.md b/solara/website/pages/documentation/getting_started/content/05-fundamentals/50-state-management.md index 0831e9a9d..98f44bcbd 100644 --- a/solara/website/pages/documentation/getting_started/content/05-fundamentals/50-state-management.md +++ b/solara/website/pages/documentation/getting_started/content/05-fundamentals/50-state-management.md @@ -33,21 +33,26 @@ def Page(): In this case, the `SomeAppSpecificComponent` is not reusable in the sense that a second component has a different state. The `color` variable is global and shared across all components. This component is meant to be used only once, and mainly helps to organize the code. -## Local component state using solara.use_state +You may have heard that globals are considered a bad practice. As with many things, it depends on the context. A possible downside of using a global is that it does not +allow you to create multiple instances of the same component with different states. But if the state reflects application state, there is by definition only one instance +of it needed. -[`solara.use_state`](/documentation/api/hooks/use_state) is a hook that allows you to manage local state within a specific component. This approach is beneficial when you want to encapsulate state within a component, making it self-contained and modular. Local state management is suitable for situations where state changes only affect the component and do not need to be shared across the application. +### Local component state using solara.use_reactive -Example: -```solara +If you do need state that is specific to a component, you should use [`solara.use_reactive`](/documentation/api/hooks/use_reactive) hook. This hook allow you to create local state variables that are scoped to a specific component. This approach is useful when you want to encapsulate state within a component, making it self-contained and modular. Local state management is suitable for situations where state changes only affect the component and do not need to be shared across the application. + + +```solara hl_lines="6 8" import solara + @solara.component def ReusableComponent(): - # color = solara.use_reactive("red") # another possibility - color, set_color = solara.use_state("red") # local state + color = solara.use_reactive("red") # local state (instead of top level solara.reactive) solara.Select(label="Color",values=["red", "green", "blue", "orange"], - value=color, on_value=set_color) - solara.Markdown("### Solara is awesome", style={"color": color}) + value=color) + solara.Markdown("### Solara is awesome", style={"color": color.value}) + @solara.component def Page(): @@ -57,22 +62,27 @@ def Page(): ``` -## Local component state using solara.use_reactive +### Local component state using solara.use_state (not recommended) +[`solara.use_state`](/documentation/api/hooks/use_state) is a hook that might be a bit more familiar to React developers. It also allows you to create local state variables that are scoped to a specific component, however, instead of using reactive variables, it uses a tuple of a value and a setter function. -`use_reactive` is the middle ground between `use_state` and `reactive`. It allows you to create a reactive variable that is scoped to a specific component. This is more a matter of taste, we generally recommend using `use_reactive`, but if you prefer a little less magic, you can use `use_state` instead. +We generally recommend using `use_reactive` over `use_state` as it is more easy to refactor between global application state and local component state by switching between `use_reactive` and `reactive`. There is no equivalent for `use_state` at the global level. +If we take the previous example and replace `use_reactive` by `use_state`, we get: -If we take the previous example using `use_state`, are replace `use_state` by `use_reactive`, we get: -```solara +Example: +```solara hl_lines="6 9" import solara + @solara.component def ReusableComponent(): - color = solara.use_reactive("red") # another possibility + # color = solara.use_reactive("red") # instead of use_reactive (not recommended) + color, set_color = solara.use_state("red") # local state solara.Select(label="Color",values=["red", "green", "blue", "orange"], - value=color) - solara.Markdown("### Solara is awesome", style={"color": color.value}) + value=color, on_value=set_color) + solara.Markdown("### Solara is awesome", style={"color": color}) + @solara.component def Page(): @@ -82,6 +92,186 @@ def Page(): ``` +## Mutation pittfalls + +In Python, strings, numbers, and tuples are immutable. This means that you cannot change the value of a string, number, or tuple in place. +Instead, you need to create a new object and assign that to a variable. + +```python +a = 1 +b = a +# a.value = 2 # ERROR: numbers are immutable +a = 2 # instead, re-assign a new value, the number 1 will not change +assert b == 1 # b is still 1 +``` +However, many objects in Python are mutable, including lists and dictionaries or potentially user defined classes. This means that you can change the value of a list, dictionary, or user defined class in place without creating a new object. +```python +a = [1, 2, 3] +b = a # b points to the same list as a +a.append(4) # a is now [1, 2, 3, 4] +assert b == [1, 2, 3, 4] # b is also [1, 2, 3, 4] +``` + +### Not mutating lists + +However, mutations in Python are not observable. **This means that if you change the value of a list, dictionary, or user defined class, Solara does not know that the value has changed and does not know it needs to re-render a component that uses that value.** + +```python +import solara + +reactive_list = solara.reactive([1, 2, 3]) +# The next line will not trigger a re-render of a component +reactive_list.value.append(4) # ERROR: mutating a list is not observable in Python +``` + +Although Solara could potentially track mutations of lists and dictionaries, that would be difficult to do for user defined classes, since we would need to know which methods mutate the object and which do not. Therefore, we have chosen not to include +any magic tracking of mutations in Solara, and instead require you to re-assign a new value to a reactive variable if you want to trigger a re-render. + +```python hl_lines="5" +import solara + +reactive_list = solara.reactive([1, 2, 3]) +# Instead, re-assign a new value +reactive_list.value = [*reactive_list.value, 4] # GOOD: re-assign a new list +``` + +### Not mutating dictionaries + +A similar pattern applies to dictionaries. + +```python +import solara + +reactive_dict = solara.reactive({"a": 1, "b": 2}) +reactive_dict.value = {**reactive_dict.value, "c": 3} # GOOD: re-assign a new dictionary +# deleting a key +reactive_dict.value = {k: v for k, v in reactive_dict.value.items() if k != "a"} # GOOD: re-assign a new dictionary +# deleting a key (method 2) +dict_copy = reactive_dict.value.copy() +del dict_copy["b"] +reactive_dict.value = dict_copy # GOOD: re-assign a new dictionary +``` + +### Not mutating user defined classes + +Or user defined classes, like a Panda DataFrame. + +```python +import solara +import pandas as pd + +reactive_df = solara.reactive(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})) +# reactive_df.value.append({"a": 4, "b": 7}) # BAD: mutating a DataFrame is not observable in Python +df_copy = reactive_df.value.copy(deep=True) # for Pandas 3, deep=True is not necessary +df_copy = df_copy.append({"a": 4, "b": 7}, ignore_index=True) +reactive_df.value = df_copy # GOOD: re-assign a new DataFrame +``` + + +## Creating a store + +Using reactive variables is a powerful way to manage state in your Solara applications. However, as your application grows, you may find that you need a more structured way to manage your state. + +In larger applications, you may want to create a store to manage your application's state. A store is a regular Python class where all attributes are reactive variables. + +In your Python class you are free to expose the reactive variables as attributes, or you can create properties to make certain attributes read-only or to add additional logic when setting an attribute. + +A complete TODO application demonstrates this below. + +```solara +import uuid +from typing import Callable + +import solara + + +# this todo item is only a collection of reactive values +class TodoItem: + def __init__(self, text: str, done: bool = False): + self.text = solara.reactive(text) + self.done = solara.reactive(done) + self._uuid = solara.reactive(str(uuid.uuid4())) + self._dirty = solara.reactive(True) + + def __str__(self) -> str: + return f"{self.text.value} ({'done' if self.done else 'not done'})" + + +# However, this class really adds some logic to the todo items +class TodoStore: + def __init__(self, items: list[TodoItem]): + # we keep the items as a protected attribute + self._items = solara.reactive(items) + self.add_item_text = solara.reactive("") + + @property + def items(self): + # and make the items read only for a property + return self._items.value + + def add_item(self, item): + self._items.value = [*self._items.value, item] + # reset the new text after adding a new item + self.add_item_text.value = "" + + def add(self): + self.add_item(TodoItem(text=self.add_item_text.value)) + + def remove(self, item: TodoItem): + self._items.value = [k for k in self.items if k._uuid.value != item._uuid.value] + + @property + def done_count(self): + return len([k for k in self.items if k.done.value]) + + @property + def done_percentage(self): + if len(self.items) == 0: + return 0 + else: + return self.done_count / len(self.items) * 100 + + +@solara.component +def TodoItemCard(item: TodoItem, on_remove: Callable[[TodoItem], None]): + with solara.Card(): + solara.InputText("", value=item.text) + solara.Switch(label="Done", value=item.done) + solara.Button("Remove", on_click=lambda: on_remove(item)) + + +# The TodoApp component is reusable, so in the future +# we could have multiple TodoApp components if needed +# (e.g. multiple lists of todos) + +default_store = TodoStore( + [ + TodoItem(text="Write a blog post", done=False), + TodoItem(text="Take out the trash", done=True), + TodoItem(text="Do the laundry", done=False), + ] +) + + +@solara.component +def TodoApp(store: TodoStore = default_store): + for item in store.items: + TodoItemCard(item, on_remove=store.remove) + + with solara.Card("New item"): + solara.InputText(label="Text", value=store.add_item_text) + solara.Button("Add new", on_click=store.add) + solara.ProgressLinear(value=store.done_percentage) + + +@solara.component +def Page(): + TodoApp() +``` + + + + ## Conclusion Understanding the advantages and disadvantages of reusable components and application-specific code can help you strike the right balance between modularity and simplicity when building your Solara applications.