Skip to content

Commit

Permalink
docs: update state management documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
maartenbreddels committed Sep 12, 2024
1 parent aa871a3 commit e3cbd88
Show file tree
Hide file tree
Showing 2 changed files with 224 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -111,30 +111,33 @@ 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
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.
Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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():
Expand All @@ -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.

Expand Down

0 comments on commit e3cbd88

Please sign in to comment.