Build a To-Do app in Python with Dash (part 2/3)

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:

  1. Tasks are lost when the page reloads
  2. 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 refresh
  • session: Data persists until the browser tab is closed
  • local: 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:

  1. Output is now the store data instead of the container
  2. We take the current store data as State
  3. 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.

Illustration of the navigation sidebar with title and progress bar for each task list.
Illustration of the navigation sidebar with title and progress bar for each task list.

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!