In part 1, we built a basic To-Do application with Dash and Dash Mantine Components (DMC). We created a single task list where users could add, modify, and delete tasks. However, our app had two major limitations:
- Tasks are lost when the page reloads
- Users can only manage one list at a time
In this tutorial, we’ll solve these problems by making tasks persistent using browser storage, and supporting multiple task lists by modifying the current callbacks.
Here’s a live display of the app you’ll learn to build (or click here):
Let’s dive in!
Part 1: Making tasks persistent
When we reload our current app, all tasks are reset to their initial state. This happens because Dash reinitializes all variables when the page reloads. To fix this, we need a way to save our tasks somewhere.
There are several options for persistence:
- Database (SQL, MongoDB, etc.)
- File system
- Browser storage
For simplicity, we’ll use browser storage through Dash’s built-in dcc.Store
component. This component can save data in the browser’s:
memory
: Data is lost on page refreshsession
: Data persists until the browser tab is closedlocal
: Data persists even after closing the browser
As we want to keep tasks over the time, local
seems a perfect fit.
Note: using local
means that the information is stored in the localStorage of the browser. It will stay as long as the user do not clean its browser / reset history.
1. Adding storage component
First, let’s modify our app to use dcc.Store
. We’ll move our task list data into the store:
app.layout = dmc.MantineProvider(
[
dmc.Container(
get_list_layout(), # No data passed here anymore
size=400,
),
dcc.Store("list_data_memory", data=sample_list_data, storage_type="local"),
dcc.Store("current_index_memory", data=sample_list_data[0]["index"], storage_type="local"),
]
)
We wrap our layout in a list to include both the container and store.
The functionget_list_layout()
no longer receives data directly. Instead, a new callback will load the data from the list_data_memory
directly to the components that need to be updated (list’ title, tasks, …).
Finally, we also add a current_index_memory
to save the index of the currently displayed list.
2. Updating the layout functions
Our get_list_layout()
function needs to change since it won’t receive data directly:
def get_list_layout():
""" Returns the list of checkboxes """
content = dmc.Paper(
[
dmc.Title(id="main_list_title", order=2),
dmc.Container(
id="main_task_container",
px=0,
mt="md",
mb="md",
),
dmc.Button(
"Add a new task",
id="new_task_button",
style={"width": "100%"},
variant="outline",
color="gray",
)
],
shadow="sm",
p="md",
mt="md",
radius="sm",
)
return content
As you can see, we removed the list_data
parameter. Instead of populating the layout with data, we add IDs to the title and task container. We will populate them using callbacks reading from list_data_memory
.
3. Adding update callback
Let’s build a callback to update our container when the stored data changes:
@app.callback(
Output("main_task_container", "children"),
Output("main_list_title", "children"),
Input("list_data_memory", "data"),
)
def update_task_container(list_data):
""" Updates the list of tasks and list title"""
return get_tasks_layout(list_data["tasks_list"]), list_data["title"]
This callback listens for changes in our stored data and updates both the task list and list title. that way, we’re sure that the layout will be updated every time when list_data_memory
changes.
We set the prevent_initial_call=False
(implicity) because we actually want this callback to populate the layout using the stored data from local storage at loading time.
4. Modifying task callbacks
Now we need to update our task-related callbacks to work with stored data. Instead of directly modifying the layout, they’ll update the stored data:
@app.callback(
Output("list_data_memory", "data", allow_duplicate=True),
Input("new_task_button", "n_clicks"),
State("list_data_memory", "data"),
prevent_initial_call=True,
)
def add_task(n_clicks, list_data):
""" Adds a task to the list """
if not n_clicks:
raise PreventUpdate
# Create new task dictionary
new_index = uuid.uuid4().hex
new_task = {
"index": new_index,
"content": "",
"checked": False,
}
# Add new task to the tasks list in memory
list_data["tasks_list"].append(new_task)
return list_data
The main changes are:
- Output is now the store data instead of the container
- We take the current store data as State
- We modify and return the stored data
We’ll do the same for the remove task callback:
@app.callback(
Output("list_data_memory", "data", allow_duplicate=True),
Input({"type": "task_del", "index": ALL}, "n_clicks"),
State("list_data_memory", "data"),
prevent_initial_call=True,
)
def remove_task(n_clicks, list_data):
""" Remove a task from the list """
if not any(n_clicks):
raise PreventUpdate
task_index = ctx.triggered_id["index"]
# Find and remove the task with matching index
list_data["tasks_list"] = [
task for task in list_data["tasks_list"]
if task["index"] != task_index
]
return list_data
Apart from the fact that we can persist the data, this way of updating a dcc.Store
is a better, cleaner way than relying directly on the container. We have more control on the stored data, on its form and when it is updated.
5. Adding task update callback
We also need to handle updating task content and checked status:
@app.callback(
Output("list_data_memory", "data", allow_duplicate=True),
Input({"type": "task_checked", "index": ALL}, "checked"),
Input({"type": "task_content", "index": ALL}, "value"),
State("list_data_memory", "data"),
prevent_initial_call=True,
)
def update_task_checked(checked_values, content_values, list_data):
"""Updates the checked state and content of tasks"""
if not checked_values:
raise PreventUpdate
# Find the index position in our list of tasks
task_index = ctx.triggered_id["index"]
task_pos = [task["index"] for task in list_data["tasks_list"]].index(task_index)
task_checked_value = checked_values[task_pos]
task_content_value = content_values[task_pos]
# Update the task values in list_data
list_data["tasks_list"][task_pos]["checked"] = task_checked_value
list_data["tasks_list"][task_pos]["content"] = task_content_value
return list_data
6. Run the app
Now our tasks persist across page reloads! You can try it by adding some tasks and reloading this page, you tasks should still be here 🎉.
➡️ See the full code on Github or open the app.
Part 2: Supporting multiple lists
You will be surprised how easy it is now to handle the multiple list. As we have a store → layout scheme, we only need to modify the way that the data is structured and modify the layout to add this feature.
1. Updated data structure
First, let’s change our data structure to support multiple lists:
sample_list_data = [
{
"index": uuid.uuid4().hex,
"title": "My Tasks",
"tasks_list": [
{
"index": uuid.uuid4().hex,
"content": "Task A",
"checked": True,
},
# ... other tasks
],
},
{
"index": uuid.uuid4().hex,
"title": "Shopping list",
"tasks_list": [],
}
]
As you can see, we simply wrap the old dict
inside a list, and add another example. We also anticipate and add a unique ID (index
) because soon or later we will need to identify lists from each other.
2. Adding list navigation
Let’s add a sidebar for list navigation. Each list will be represented as a card with a title and a progression bar.
A new function get_list_navigation_layout
will handle this:
def get_list_navigation_layout(list_data, current_index):
""" Returns a list of lists titles and progressions """
items = []
for list_item in list_data:
# Compute progression
progress_value = get_progression(list_item)
progress_color = "green" if progress_value == 100 else "blue"
# Build card element
elem = dmc.Paper(
html.A(
[
dmc.Title(list_item["title"], order=4, mb="sm"),
dmc.Progress(value=progress_value, color=progress_color),
],
id={"type": "list_button", "index": list_item["index"]},
style={"cursor": "pointer"},
),
p="xs",
mb="sm",
withBorder=True,
className="active" if current_index == list_item["index"] else ""
)
items.append(elem)
return items
def get_progression(list_item):
""" Computes the progression of a list """
tasks = list_item["tasks_list"]
if len(tasks) == 0:
return 0
return len([task for task in tasks if task["checked"]]) / len(tasks) * 100
We simply iterate over the lists in list_data
. For each list, we compute the progression (the progress bar turns green at 100% instead of blue) and make the cards with title and progress bar.
Notice that we wrapped the content inside a html.A
button. That way, we make the card clickable and we will be able to create a callback listening for clicks (n_clicks
) on the card component.
The current_index
parameter will store the index
of the list we’re currently displaying. We use it to set the CSS class “active” to this list, making it focused compared to the others.
/* Filepath: assets/style.css */
/* ... (previous code) ... */
#list_navigation_layout > div {
opacity: 0.7;
}
/* Opacity is a good way to emphasize some elements */
#list_navigation_layout > div.active {
border-color: black;
opacity: 1.0;
}
We also need a “New list” button:
def get_new_list_button():
return dmc.Button(
"New list",
id="new_list_button",
style={"width": "100%"},
color="black",
mt="md",
mb="md"
)
3. Updated app layout
Finally, we use DMC’s grid system to create a two-panel layout:
app.layout = dmc.MantineProvider(
[
dmc.Container(
dmc.Grid(
[
dmc.GridCol(
[
get_new_list_button(), # The new list button
dmc.Container( # This container will be updated dynamically
id="list_navigation_layout",
px=0,
)
],
span=4,
),
dmc.GridCol(
get_list_layout(),
span="auto",
ml="xl"
),
],
gutter="md"
),
size=600,
),
# The stored data in localStorage
dcc.Store("list_data_memory", data=sample_list_data, storage_type="local"),
dcc.Store("current_index_memory", data=sample_list_data[0]["index"], storage_type="local"),
]
)
The left panel serves as a navigation sidebar, displaying all available lists with their progress bars. The right panel shows the tasks of the currently selected list, maintaining our familiar task interface but adding an editable title and a list deletion option.
Here’s the interactive version of the new layout:
➡️ See the full code on Github or open the app.
In the next section, we’ll add the necessary callbacks to make this layout interactive.
4. List management callbacks
With multiple lists to manage, we need callbacks to handle list-level operations.
The list switching callback responds to clicks in the navigation sidebar, updating current_index_memory
to display the selected list:
@app.callback(
Output("current_index_memory", "data", allow_duplicate=True),
Input({"type": "list_button", "index": ALL}, "n_clicks"),
prevent_initial_call=True,
)
def switch_list(n_clicks):
""" Changes the current displayed list"""
if not any(n_clicks):
raise PreventUpdate
list_index = ctx.triggered_id["index"]
return list_index
When users create a new list, the add_list
callback adds it to our collection and automatically makes it the current list by updating current_index_memory
.
@app.callback(
[
Output("list_data_memory", "data", allow_duplicate=True),
Output("current_index_memory", "data"),
],
Input("new_list_button", "n_clicks"),
State("list_data_memory", "data"),
prevent_initial_call=True,
)
def add_list(n_clicks, list_data):
""" Add a new list and display it"""
if not n_clicks:
raise PreventUpdate
new_index = uuid.uuid4().hex
new_list = {
"index": new_index,
"title": "New list",
"tasks_list": [],
}
list_data.append(new_list)
return list_data, new_index
You’ll notice how the code is similar to the add_task
callback.
List deletion works in two steps: first showing a confirmation modal, then removing the list if confirmed. That way, the UX (User Experience) is better and avoids mistakingly deleting lists! 🙂
@app.callback(
Output("del_list_modal", "opened"),
[
Input("del_list_button", "n_clicks"),
Input("del_list_modal_confirm_button", "n_clicks"),
],
prevent_initial_call=True,
)
def delete_modal_open_close(n_clicks, n_clicks2):
""" Open or closes modal """
if not n_clicks:
raise PreventUpdate
if ctx.triggered_id == "del_list_modal_confirm_button":
return False
return True
@app.callback(
[
Output("list_data_memory", "data", allow_duplicate=True),
Output("current_index_memory", "data", allow_duplicate=True),
],
[
Input("del_list_modal_confirm_button", "n_clicks"),
State("list_data_memory", "data"),
State("current_index_memory", "data"),
],
prevent_initial_call=True,
)
def delete_list(n_clicks, list_data, current_index):
""" Updates the current list title """
if not n_clicks:
raise PreventUpdate
# Remove the current list
i = get_pos_from_index(list_data, current_index)
del list_data[i]
# Focus again on the first index if there are notes
current_index = list_data[0]["index"] if len(list_data) > 0 else None
return list_data, current_index
We used ctx.triggered_id
to differentiate whether we needed to open or close the modal. It’s a simple way to update the same component without relying on allow_duplicate=True
and making two separate callbacks.
Note: you might wonder why the Outputs and Inputs are now wrapped inside lists: [Output(…), Output(…)]
. In this case, this is just syntax sugar that helps visualizing what are the inputs / outputs easily. I often do this as the outputs or inputs grows.
We’ve added a helper function get_pos_from_index
that simplifies finding lists in our data structure – it’s used throughout our callbacks to ensure we’re modifying the correct list:
def get_pos_from_index(dict_list, index):
""" Gets position of dict with matching index in a list
Example:
* get_pos_from_index([{"index": "abc"}, {"index": "def"}], "def")
* returns 1
"""
for i, elem in enumerate(dict_list):
if elem["index"] == index:
return i
return None
The helper work both for lists and for tasks.
5. Updated task callbacks
Finally, we need to update our task callbacks to work with the currently displayed list (using current_index_memory
):
@app.callback(
Output("list_data_memory", "data", allow_duplicate=True),
[
Input("new_task_button", "n_clicks"),
State("list_data_memory", "data"),
State("current_index_memory", "data"),
],
prevent_initial_call=True,
)
def add_task(n_clicks, list_data, current_index):
""" Adds a task to the list """
if not n_clicks:
raise PreventUpdate
# Create new task dictionary
new_index = uuid.uuid4().hex
new_task = {
"index": new_index,
"content": "",
"checked": False,
}
# Add new task to the tasks list in memory
i = get_pos_from_index(list_data, current_index)
list_data[i]["tasks_list"].append(new_task)
return list_data
Similar updates are needed for the remove and update task callbacks. The key change is using get_pos_from_index()
to find the current list in our data and updating it.
6. Run the app
Here is the final result. Try switch from a list to another, add list, remove, modify tasks… And reload the page!
Here’s the interactive version of the new layout:
➡️ See the full code on Github or open the app.
Conclusion
Throughout this tutorial, we’ve taken our basic task list and transformed it into a more powerful application. We could go even further: adding user login/register, share task lists, etc. Feel free to implement them on your side!
In this tutorial we’ve covered key concepts:
- Using browser storage with
dcc.Store
- Managing complex state across multiple components
- Building modular layouts with DMC Grid
- Handling nested data structures in callbacks
In the final part of this series, we’ll look at improving performance using Dash’s Patch system.
You can find the complete code for this tutorial on Github: https://github.com/Spriteware/dash-plotly-todo-app/
I hope you enjoyed this tutorial. ⭐
You can ask questions on the associated topic on Plotly’s community forum: here.
Be sure to subscribe to the newsletter to see the next articles!