Signals, a new feature introduced by Preact Team. With its automatic state binding and dependency tracking makes Signals a better way to manage state. Let's see what it is and how it is unique.
Signals
Signals are reactive primitives for managing application state. It is a unique way to deal with the state with automatic update of components and UI in the most efficient way possible.
Preact says, Signals provide great ergonomics based on automatic state binding and dependency tracking and have a unique implementation optimized for the virtual DOM.
Signal at its core
Signal at its core is an object with a .value
property that holds a value. It means the value of the signal object can change, but the signal itself always remains the same.
The signal can be updated without re-rendering any components, since components see the signal and not its value (we're only passing around references to the signal). It skips heavy rendering work and jumps immediately to actual components in the tree that access the signal's .value
property.
The second important characteristic is that they track when their value is accessed and when it is updated. Accessing a signal's .value
property from within a component automatically re-renders the component when that signal's value changes.
State management
Let's build a basic TodoList demo app to understand usage of Signal as state management.
1. Installation
We will use @preact/signals-react with react in our demo. To install the package, we use command npm install @preact/signals-react
.
Signals can be integrated into other frameworks as if they were native built-in primitives. Signals can be accessed directly in components, and your component will automatically re-render when the signal's value changes.
# Just the core library
npm install @preact/signals-core
# If you're using Preact
npm install @preact/signals
# If you're using React
npm install @preact/signals-react
# If you're using Svelte
npm install @preact/signals-core
2. Usage
Let's create a state containing a to-do list using Signal, which can be represented by an array:
import { signal } from "@preact/signals-react";
const todos = signal([
{ text: "Write my first post on DEV community", completed: true },
{ text: "Explore more into Preact Signals feature", completed: false },
]);
Next, We add two functions to create a new to-do item and remove a to-do. We also need to create a signal for the input value, and then set it directly .value
on input text modified.
const newItem = signal("");
function addTodo() {
todos.value = [...todos.value, { text: newItem.value, completed: false }];
newItem.value = ""; // Reset input value on add
}
function removeTodo(index) {
todos.value.splice(index, 1)
todos.value = [...todos.value];
}
3. Global state with context
Now let's add all these into context and create our app state. Below is context provider and function to consume it with state, addTodo, removeTodo functions.
import React, { useContext } from "react";
import { signal } from "@preact/signals-react";
const AppState = React.createContext();
function AppStateProvider(props) {
const todos = signal([
{ text: "Write my first post on DEV community", completed: true },
{ text: "Explore more into Preact Signals feature", completed: false }
]);
function addTodo(newItem) {
todos.value = [...todos.value, { text: newItem.value, completed: false }];
}
function removeTodo(index) {
todos.value.splice(index, 1);
todos.value = [...todos.value];
}
return (
<AppState.Provider value={{ todos, addTodo, removeTodo }}>
{props.children}
</AppState.Provider>
);
}
function useAppState() {
const appState = useContext(AppState);
if (!appState) {
throw new Error("useAppState must be used within a AppStateProvider");
}
return appState;
}
export { AppStateProvider, useAppState };
Now we need to build our user interface which consumes the state from our context and displays our to-do list. We also need to integrate addTodo and removeTodo functionality.
First, let's wrap our TodoList component with context provider in the main app component.
import TodoList from "./Todolist";
import { AppStateProvider } from "./AppStateContext";
export default function App() {
return (
<div className="App">
<h1>Signals</h1>
<h2>npm install @preact/signals-react</h2>
<AppStateProvider>
<TodoList />
</AppStateProvider>
</div>
);
}
Below is our TodoList UI component.
import { signal } from "@preact/signals-react";
import { useAppState } from "./AppStateContext";
const newItem = signal("");
export default function TodoList() {
const { todos, addTodo, removeTodo } = useAppState();
const onInput = (event) => (newItem.value = event.target.value);
const onAddClick = () => {
addTodo(newItem);
newItem.value = "";
};
return (
<>
<input type="text" value={newItem.value} onInput={onInput} />
<button style={{marginLeft:'10px'}} onClick={onAddClick}>Add</button>
<ul style={{ textAlign: "left" }}>
{todos.value.map((todo, index) => {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onInput={() => {
todo.completed = !todo.completed;
todos.value = [...todos.value];
}}
/>
{todo.completed ? <s>{todo.text}</s> : todo.text}{" "}
<button style={{marginLeft:'10px', color: 'red'}} onClick={() => removeTodo(index)}>x</button>
</li>
);
})}
</ul>
</>
);
}
Below is how it looks after integrating components together in our demo app. You can experience full functionality here
Other functions from Signals API
computed(fn)
The computed
function lets you combine the values of multiple signals into a new signal. It is useful in instances where data is derived from other pieces of existing state.
For instance, we want to compute the number of completed to-do items, the below piece of code should do that.
const completedCount = computed(() => {
return todos.value.filter((todo) => todo.completed).length;
});
When any of the signals used in a computed callback change, the computed callback is re-executed and its new return value becomes the computed signal's value.
effect(fn)
The effect
function makes everything reactive. When you access a signal inside its callback function, that signal and every dependency of said signal will be activated and subscribed to. It is very similar to computed(fn)
, unlike computed signals, effect() does not return a signal.
import { signal, computed, effect } from "@preact/signals-react";
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => name.value + " " + surname.value);
// Logs: "Jane Doe"
effect(() => console.log(fullName.value));
// Updating one of its dependencies will automatically trigger
// the effect above, and will print "John Doe" to the console.
name.value = "John";
batch(fn)
The batch
function allows you to combine multiple signal writes into one single update that is triggered at the end when the callback completes.
Sometimes we may have multiple updates at the same time, but we don’t want to trigger multiple renders, so we need to merge state updates into one action.
Let's look at our addTodo
and clearing the input's newItem.value
, we can merge these two actions into one batch and make it one commit at the end of the callback.
const onAddClick = () => {
batch(() => {
addTodo(newItem);
newItem.value = "";
});
};
signal.peek()
The .peek()
function allows getting the signal's current value without subscribing to it. It is useful in instances where you have an effect that should write to another signal based on the previous value, but you don't want the effect to re-run when that signal changes.
const counter = signal(0);
const effectCount = signal(0);
effect(() => {
//Reacts to `counter` signal but not to `effectCount` signal
effectCount.value = effectCount.peek() + counter.value;
});
TodoList demo app codesandboxurl
Top comments (0)