Creating a Todo app
Now that we have gone through all the basics, let’s put everything we learned together to build a simple Todo app.
Setting up our state
Let’s start by defining what our data looks like.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Todo {
task: Signal<String>,
completed: Signal<bool>,
id: u32,
}
Initially, we won’t have any todos so we can simply add:
#[component]
fn App() -> View {
let todos = create_signal(Vec::new());
...
}
We will need to, as discussed in the previous section, have a way of generating
unique IDs for all our todos. Here, we will do so with a simple incrementing
counter. In our App
component, create the get_next_id
function:
let next_id = create_signal(0);
// `replace(...)` is the same as `set(...)` but returns the previous value.
let get_next_id = move || next_id.replace(next_id.get() + 1);
Let’s also abstract away from creating and removing new todos:
let add_todo = move |task| {
todos.update(|todos| {
todos.push(Todo {
task: create_signal(task),
completed: create_signal(false),
id: get_next_id(),
})
})
};
let remove_todo = move |id| todos.update(|todos| todos.retain(|todo| todo.id != id));
Creating the UI
Displaying the list
Let’s start by creating a component for displaying the contents of our list.
#[component(inline_props)]
fn TodoItem(todo: Todo) -> View {
// Dispose of nested signals when the todo is removed.
on_cleanup(move || {
todo.task.dispose();
todo.completed.dispose();
});
// We are using inline styles here which is generally not considered best practice.
// In real app, you would probably use an external CSS file.
let style = move || {
if todo.completed.get() {
"text-decoration: line-through;"
} else {
""
}
};
view! {
li {
span(style=style) { (todo.task) }
}
}
}
#[component(inline_props)]
fn TodoList(#[prop(setter(into))] todos: MaybeDyn<Vec<Todo>>) -> View {
view! {
ul {
Keyed(
list=todos,
view=|todo| view! { TodoItem(todo=todo) },
key=|todo| todo.id,
)
}
}
}
We’ve decided here to represent the completed status of a todo by putting a strikethrough the text.
Creating a new todo input
Let’s also create a component representing the input box for creating a new
todo. This component will accept a callback for adding a todo, which will be
passed from our App
component with the add_todo
closure.
#[component(inline_props)]
fn TodoInput<F>(add_todo: F) -> View
where
F: Fn(String) + 'static,
{
let input = create_signal(String::new());
let on_keydown = move |ev: KeyboardEvent| {
if ev.key() == "Enter" && !input.with(String::is_empty) {
add_todo(input.get_clone());
// Reset the input.
input.set(String::new());
}
};
view! {
div {
"New Todo: "
input(bind:value=input, on:keydown=on_keydown)
}
}
}
Here, we also introduce a new concept: data-binding. The bind:*
attribute
binds the value of a signal to the value of the input. That means that every
time the input is changed, the signal will automatically be updated. It also
works the other way around: every time the signal is updated, the input box will
be updated as well.
Finally, we create a new event handler this is triggered on every key press. This event handler checks if the key is “Enter” and if we actually have something in the task input. If so, it calls the callback to create a new todo and resets the input.
Drawing the components
Let’s put all these components together by calling them from App
:
view! {
TodoInput(add_todo=add_todo)
TodoList(todos=todos)
}
Run trunk serve
and open up your browser at http://localhost:8080 and see it
work for yourself!
Modifying todos
Right now, we can add new tasks to our todo list, but not much else. Let’s fix that.
Changing completed state
Let’s allow changing the completed state of a todo by simply clicking on the
text. In the TodoItem
component, add a new event handler toggle_completed
which toggles the completed status of the current todo.
let toggle_completed = move |_| todo.completed.set(!todo.completed.get());
view! {
li {
span(style=style, on:click=toggle_completed) { (todo.task) }
}
}
Since we already wired up the style to reflect whether a todo was completed or not, this should just work.
Removing a todo
This is slightly more complicated. Let’s create a button for every todo that,
when clicked, removes the todo from the list. So our TodoItem
component will
need to accept a callback that can remove a todo from the list. Change the
TodoItem
function to have the following signature:
#[component(inline_props)]
fn TodoItem<F>(todo: Todo, remove_todo: F) -> View
where
F: Fn(u32) + Copy + 'static,
{ ... }
Of course, since TodoItem
is called from TodoList
, we also need TodoList
to accept a closure as a prop so we will need to modify TodoList
:
#[component(inline_props)]
fn TodoList<F>(#[prop(setter(into))] todos: MaybeDyn<Vec<Todo>>, remove_todo: F) -> View
where
F: Fn(u32) + Copy + 'static,
{
view! {
ul {
Keyed(
list=todos,
view=move |todo| view! { TodoItem(todo=todo, remove_todo=remove_todo) },
key=|todo| todo.id,
)
}
}
}
And finally pass the closure in from App
:
view! {
TodoList(todos=todos, remove_todo=remove_todo)
}
Now, we can finally create a button for removing our todo inside TodoItem
:
let remove_todo = move |_| remove_todo(todo.id);
view! {
li {
span(style=style, on:click=toggle_completed) {
(todo.task)
}
button(on:click=remove_todo) { "Remove" }
}
}
That was quite a lot of work, just for removing a few todos. This reveals a more general problem, how do we pass data deep into the component hierarchy without threading it through every single component in between (a pattern known as “prop-drilling”)?
One solution is to use the Context API which we will not elaborate more on here.
Editing todos
One final feature we’ll add is allow modification of already existing todos.
This time, instead of adding an is_editing
field to our Todo
struct, we will
just create a signal directly inside TodoItem
.
let is_editing = create_signal(false);
let start_editing = move |_| is_editing.set(true);
let on_keydown = move |ev: KeyboardEvent| {
if ev.key() == "Enter" && !todo.task.with(String::is_empty) {
is_editing.set(false);
}
};
view! {
li {
span(style=style, on:click=toggle_completed) {
(if is_editing.get() {
view! { input(bind:value=todo.task, on:keydown=on_keydown) }
} else {
view! { (todo.task) }
})
}
button(on:click=start_editing, disabled=is_editing.get()) { "Edit Task" }
button(on:click=remove_todo) { "Remove" }
}
}
This creates a new signal representing whether we are currently editing our task
or not. If we are editing, we show a input box where we can change the task. And
similarly to TodoInput
from before, we listen to the keydown
event to
determine when we are done editing.
Storing todos in local storage
Right now, if we refresh the page in the browser, all of our todos are lost forever. We want to persist our todos across page refreshes. To do so, we can use the browser’s local storage API.
However, our state is currently stored inside Rust data types and local storage
can only store strings. We must therefore serialize our state, and deseralize it
on initialization. Start by adding some more dependencies: serde
and
serde_json
for serialization and deserialization, and web-sys
for accessing
the local storage API. We’ll also want to enable the serde
flag on sycamore
so that we can easily serialize Signal
s.
cargo add serde -F derive
cargo add serde_json
cargo add web-sys -F Storage
cargo add sycamore -F serde
Then replace the create_signal
function call with:
// Initialize application state from localStorage.
let local_storage = window()
.local_storage()
.unwrap()
.expect("user has not enabled localStorage");
let todos: Signal<Vec<Todo>> = if let Ok(Some(app_state)) = local_storage.get_item("todos") {
serde_json::from_str(&app_state).unwrap_or_default()
} else {
Default::default()
};
This will try to deserialize the state from local storage, if it exists, or set the state to a blank list otherwise.
Finally, we also want to save the state every time it changes:
// Set up an effect that runs whenever app_state.todos changes to save the todos to
// localStorage.
create_effect(move || {
todos.with(|todos| {
// Also track all nested signals.
for todo in todos {
todo.task.track();
todo.completed.track();
}
local_storage
.set_item("todos", &serde_json::to_string(todos).unwrap())
.unwrap();
});
});
Now, we have a fully functioning todo app!
Full Code
Here is the complete code listing for the todo app.
use serde::{Deserialize, Serialize};
use sycamore::prelude::*;
use web_sys::KeyboardEvent;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Todo {
task: Signal<String>,
completed: Signal<bool>,
id: u32,
}
#[component(inline_props)]
fn TodoItem<F>(todo: Todo, remove_todo: F) -> View
where
F: Fn(u32) + Copy + 'static,
{
on_cleanup(move || {
todo.task.dispose();
todo.completed.dispose();
});
// We are using inline styles here which is generally not considered best practice.
// In real app, you would probably use an external CSS file.
let style = move || {
if todo.completed.get() {
"text-decoration: line-through;"
} else {
""
}
};
let toggle_completed = move |_| todo.completed.set(!todo.completed.get());
let remove_todo = move |_| remove_todo(todo.id);
let is_editing = create_signal(false);
let start_editing = move |_| is_editing.set(true);
let on_keydown = move |ev: KeyboardEvent| {
if ev.key() == "Enter" && !todo.task.with(String::is_empty) {
is_editing.set(false);
}
};
view! {
li {
span(style=style, on:click=toggle_completed) {
(if is_editing.get() {
view! { input(bind:value=todo.task, on:keydown=on_keydown) }
} else {
view! { (todo.task) }
})
}
button(on:click=start_editing, disabled=is_editing.get()) { "Edit Task" }
button(on:click=remove_todo) { "Remove" }
}
}
}
#[component(inline_props)]
fn TodoList<F>(#[prop(setter(into))] todos: MaybeDyn<Vec<Todo>>, remove_todo: F) -> View
where
F: Fn(u32) + Copy + 'static,
{
view! {
ul {
Keyed(
list=todos,
view=move |todo| view! { TodoItem(todo=todo, remove_todo=remove_todo) },
key=|todo| todo.id,
)
}
}
}
#[component(inline_props)]
fn TodoInput<F>(add_todo: F) -> View
where
F: Fn(String) + 'static,
{
let input = create_signal(String::new());
let on_keydown = move |ev: KeyboardEvent| {
if ev.key() == "Enter" && !input.with(String::is_empty) {
add_todo(input.get_clone());
input.set(String::new());
}
};
view! {
div {
"New Todo: "
input(bind:value=input, on:keydown=on_keydown)
}
}
}
#[component]
fn App() -> View {
// Initialize application state from localStorage.
let local_storage = window()
.local_storage()
.unwrap()
.expect("user has not enabled localStorage");
let todos: Signal<Vec<Todo>> = if let Ok(Some(app_state)) = local_storage.get_item("todos") {
serde_json::from_str(&app_state).unwrap_or_default()
} else {
Default::default()
};
// Set up an effect that runs whenever app_state.todos changes to save the todos to
// localStorage.
create_effect(move || {
todos.with(|todos| {
// Also track all nested signals.
for todo in todos {
todo.task.track();
todo.completed.track();
}
local_storage
.set_item("todos", &serde_json::to_string(todos).unwrap())
.unwrap();
});
});
let next_id = create_signal(0);
// `replace(...)` is the same as `set(...)` but returns the previous value.
let get_next_id = move || next_id.replace(next_id.get() + 1);
let add_todo = move |task| {
todos.update(|todos| {
todos.push(Todo {
task: create_signal(task),
completed: create_signal(false),
id: get_next_id(),
})
})
};
let remove_todo = move |id| todos.update(|todos| todos.retain(|todo| todo.id != id));
view! {
TodoInput(add_todo=add_todo)
TodoList(todos=todos, remove_todo=remove_todo)
}
}
fn main() {
sycamore::render(App);
}