
François Andrieux
March 17, 2026
Build a To-Do App in Python with Dash (part 1)
Author: Fran Andrieux
In this tutorial, weâll build a To-Do application 100% in Python using Plotly Dash and the community extension Dash Mantine Components. Weâll take an iterative approach, starting with the basics and gradually adding complexity as we understand why each piece is needed.
The tutorial is in two parts:
- Setup the layout, handle a minimal task list (part 1)
- Handle multiple lists and save tasks on page reload (part 2)
At the end of this article, youâll know how to build the following To-Do app with Dash in python:

Introduction to Dash Mantine Components
For this project, we used Dash Mantine Components (DMC) version 0.15.1. Before we dive in, letâs understand what DMC offers us.Â
DMC is a component library that provides pre-built React components with a modern design system. It includes layout helpers (e.g. grid systems, centered container, etc.) and out-of-the-box components (e.g. Modal, Radio Button, Slider, etc.)

Some key components weâll use:
- dmc.Container: A centered container with max-width
- dmc.Paper: A white background container with optional shadow
- dmc.Grid and dmc.GridCol: Flexbox-based grid system
- dmc.Button, dmc.Checkbox, dmc.ActionIcon: Interactive components
DMC uses several spacing and positioning attributes:
- mt: margin-top (e.g., mt="md" for medium margin-top)
- mb: margin-bottom
- px: padding on x-axis (left and right)
- p: padding on all sides
- Size values: âxsâ, âsmâ, âmdâ, âlgâ, âxlâ or numeric pixels
This will be useful for the rest of the article. If you encounter something you donât understand, just go back here or search the component in DMC documentation.
Step 1: Setting up the layout
1. Data structure
Letâs start by defining how weâll store our task data. This will actually help us shape the layout.
We want to create and save tasks. Each task should have a text content, and a checkbox status. That means having something like:
sample_list_data = {    "title": "My Tasks",    "tasks_list": [        {            "content": "Task A",            "checked": True,        },        {            "content": "Task B",            "checked": False,        },        {            "content": "Task C",            "checked": False,        },    ],}
From this structure, we can build the layout function to display tasks.
2. Creating reusable layout functions
A key aspect of our design is creating separate functions for each part of the layout. This isnât just for code organization â itâs crucial for the dynamic nature of our app. Later, when we add or remove tasks, weâll need to recreate portions of the layout dynamically. By having these functions, we can easily generate new task elements or update existing ones.
Letâs start with a function to render a single task using DMCâs Grid system:
def get_task(task_dict):    """ Returns a single task layout """    text = task_dict["content"]    checked = task_dict["checked"]    content = dmc.Grid(        [            # Checkbox column            dmc.GridCol(                dmc.Checkbox(                    checked=checked,                    mt=2 # Align checkbox vertically                ),                span="content" # Take only needed space            ),            # Task text column - Using Input for editability            dmc.GridCol(                dmc.Text( # Wrap in Text component for consistent styling                    dcc.Input(                        text,                        className="shadow-input", # Custom styling for input                        debounce=True       # For callbacks                    )                ),                span="auto" # Take remaining space            ),            # Delete button column            dmc.GridCol(                dmc.ActionIcon(                    DashIconify(icon="tabler:x", width=20),                    variant="transparent",                    color="gray",                    className="task-del-button"                ),                span="content"            ),        ],        className="task-container"    )    return content
We used dmc.Grid to easily get a 3-column structure. The first column has the checkbox, the second the text content, and the third has the delete task button.

You might wonder why we use a dcc.Input wrapped in a dmc.Text component. The idea is that we want to modify the text just by clicking on it. The most similar example is the âcontenteditableâ elements in HTML: but they arenât available in Dash.
Instead, we will style the app with some CSS to make the input look like normal text when it is not focused (i.e. being modified).
Note: the debounce attribute is used to trigger the input âupdateâ event when the user finishes typing, not every time a key is pressed. In this case, it offers a better user experience.
3. Creating the tasks list and main container
Now letâs create a function to render all tasks. Itâs simply the for loop over the get_task function:
def get_tasks_layout(tasks_list):Â Â Â Â """ Returns the list of tasks """Â Â Â Â tasks = []Â Â Â Â for task_dict in tasks_list:Â Â Â Â Â Â Â Â task_layout = get_task(task_dict)Â Â Â Â Â Â Â Â tasks.append(task_layout)Â Â Â Â return tasks
Weâll wrap everything in a main container with a title and âAdd a new askâ button:
def get_list_layout(list_data):    """ Returns the list container with title and tasks """    tasks_layout = get_tasks_layout(list_data["tasks_list"])    content = dmc.Paper(        [            dmc.Title(list_data["title"], order=2),            # Tasks container            dmc.Container(                tasks_layout,                id="main_task_container",                px=0, # No horizontal padding                mt="md", # Medium margin top                mb="md", # Medium margin bottom            ),            # Add task button            dmc.Button(                "Add a new task",                id="new_task_button",                style={"width": "100%"},                variant="outline",                color="gray",            )        ],        shadow="sm", # Light shadow        p="md",    # Medium padding        mt="md",   # Medium margin top        radius="sm", # Slightly rounded corners    )    return content
4. Styling with CSS
DMC already brings a set of default style sheets, which make us gain a lot of time. But as said previously, we want our we want our task inputs to look clean and minimal, with appropriate visual feedback for user interactions:
So we create a style.css file in the assets/ folder. This will be loaded automatically by Dash:
/*ÂÂ * Filepath: ./assets/style.cssÂÂ */.shadow-input {Â Â Â Â display: inline-block;Â Â Â Â width: 100%;Â Â Â Â margin: 0;Â Â Â Â border: 1px solid black;Â Â Â Â border-color: transparent;Â Â Â Â border-radius: 5px;}.shadow-input:hover {Â Â Â Â border-color: gray;}.shadow-input:active, .shadow-input:focus {Â Â Â Â border-color: black;}.task-container:has(.mantine-Checkbox-root[data-checked]) .mantine-Text-root input {Â Â Â Â text-decoration: line-through;Â Â Â Â color: gray;}.task-del-button:hover {Â Â Â Â color: red;}
This CSS does several things:
- Makes inputs blend seamlessly into the layout with transparent borders
- Shows subtle borders on hover and focus for better UX
- Applies strikethrough styling to checked tasks
- Adds a red hover effect to delete buttons
A key design decision here is handling the strikethrough effect with CSS rather than callbacks. By using the :has() selector to detect checked checkboxes, we can apply the strikethrough style directly in CSS. This is more efficient than using a callback, as it eliminates unnecessary round-trips to the server and provides instant visual feedback.
5. Running the app
Now letâs put everything together and start our application:
import dash_mantine_components as dmcfrom dash import Dash, _dash_renderer, dccfrom dash_iconify import DashIconify_dash_renderer._set_react_version("18.2.0") # for mantine# sample_list_data = ...## Layout functions:# get_task()# get_tasks_layout()# get_list_layoutapp = Dash(__name__)app.layout = dmc.MantineProvider(    dmc.Container(        get_list_layout(sample_list_data),        size=400, # Container width    ))# Start the serverif __name__ == '__main__':    app.run_server(debug=True)
When you run this code, youâll have a basic task list application without interactivity yet:

-> See the full code on Github or open the app.
Now that we have our layout defined, we can make it interactive with callbacks.
Step 2: Adding interactivity with callbacks
Now comes the interesting part. We need to make our tasks interactive â we need to handle creating, reading, updating, and deleting tasks (CRUD operations). However, we face an interesting challenge: how do we handle callbacks for elements that donât exist when the app starts?
In regular Dash callbacks, you must define both inputs and outputs statically â they need to exist when the app initializes. This works fine for static elements like a single button or dropdown. But in our task app, weâre dynamically adding and removing tasks! These elements donât exist when the app starts running.
This is where pattern-matching callbacks come in. Instead of targeting specific IDs like âbutton-1â or âtask-input-2â, pattern-matching callbacks let us define patterns that match multiple components, even ones created after the app starts running. They work by using a dictionary-based ID system and special selectors like ALL.
For example, compare these two approaches:
# Regular callback - Only works for a specific, existing button@app.callback(    Output("static-div", "children"),    Input("submit-button", "n_clicks"))# Pattern-matching callback - Works for ANY button matching the pattern@app.callback(    Output({"type": "result", "id": ALL}, "children"),    Input({"type": "submit", "id": ALL}, "n_clicks"))
The pattern-matching version uses a dictionary for the ID with two keys:
- âtypeâ: Groups similar components (like all submit buttons)
- âidâ: Uniquely identifies each instance
Now we can write a single callback that handles all tasks of the same type, even ones created dynamically after the app starts! When we add a new task with ID {"type": "task", "id": "123"}, the callback will automatically work for it.
This is perfect for our task app where we need to add and remove tasks dynamically. Letâs continue.
Learn more on Pattern-Matching callbacks from the Dash Plotly documentation: https://dash.plotly.com/pattern-matching-callbacks
1. Adding unique identifiers
As Pattern-Matching callbacks need a unique id, we need to modify our data structure to include unique identifiers for each task. Letâs add an index field using UUID:
import uuidsample_list_data = {    "title": "My Tasks",    "tasks_list": [        {            "index": uuid.uuid4().hex, # Add unique identifier            "content": "Task A",            "checked": True,        },        # ... other tasks    ],}
Example UUID: f4da57942cec46b7ba448c88fad11996. We could as well have used integers. It doesnât matter as long as the ids are unique.
Now we need to update our get_task function to use these identifiers:
def get_task(task_dict):    """ Returns a single task layout """    text = task_dict["content"]    checked = task_dict["checked"]    index = task_dict["index"] # Get the index    content = dmc.Grid(        [            dmc.GridCol(                dmc.Checkbox(                    id={"type": "task_checked", "index": index}, # Add ID                    checked=checked,                    mt=2                ),                span="content"            ),            dmc.GridCol(                dmc.Text(                    dcc.Input(                        text,                        id={"type": "task_content", "index": index}, # Add ID                        className="shadow-input",                        debounce=True,                    )                ),                span="auto"            ),            dmc.GridCol(                dmc.ActionIcon(                    DashIconify(icon="tabler:x", width=20),                    id={"type": "task_del", "index": index}, # Add ID                    variant="transparent",                    color="gray",                    className="task-del-button"                ),                span="content"            ),        ],        className="task-container"    )    return content
We now have a unique index on the three interactive components: the checkbox, the input and the delete button. Letâs write the callbacks.
2. Adding new tasks
Now we can implement the âAdd Taskâ functionality using callbacks:
@app.callback(    Output("main_task_container", "children", allow_duplicate=True),    Input("new_task_button", "n_clicks"),    State("main_task_container", "children"),    prevent_initial_call=True,)def add_task(n_clicks, current_tasks):    """ Adds a task to the list """    if not n_clicks:        raise PreventUpdate    # Create new task with unique ID    new_index = uuid.uuid4().hex    task_dict = {        "index": new_index,        "content": "",        "checked": False,    }    task_layout = get_task(task_dict)    # Add new task to current tasks    updated_tasks = current_tasks + [task_layout]    return updated_tasks
This callback is triggered when the new_task_button is clicked. It basically takes the existing list of tasks that are in main_task_container and adds a new, empty, task inside. Then it returns the new list.
We added prevent_initial_call=True to avoid running this callback when the app is loaded (the default behavior), as we know that this callback should only be triggered on a button action. It reduces unnecessary work and HTTP requests.
We also check the n_clicks is a valid positive value, in case that the callback gets triggered anyway. The raise PreventUpdate stops the callback execution.
Notice the allow_duplicate=True on the Output. It is mandatory as we will have many operations that will update the main_task_container component.
3. Removing Tasks
And finally, we implement the âTask deletionâ callback following the same scheme:
@app.callback(    Output("main_task_container", "children", allow_duplicate=True),    Input({"type": "task_del", "index": ALL}, "n_clicks"),    State("main_task_container", "children"),    prevent_initial_call=True,)def remove_task(n_clicks, current_tasks):    """ Remove a task from the list """    if not any(n_clicks):        raise PreventUpdate    print("Entering remove_task callback")    task_index = ctx.triggered_id["index"]    # Get the list of existing ids.    all_ids = [elem["id"] for elem in ctx.inputs_list[0]]    # Find the position of element in list and remove it    for i, task_id in enumerate(all_ids):        if task_id["index"] == task_index:            del current_tasks[i]            break    return current_tasks
Hereâs how it works:
- When a delete button is clicked, Dash triggers this callback. We use pattern matching with {"type": "task_del", "index": ALL} to catch clicks from any delete button in our tasks. Each button has a unique index we assigned earlier.
- The ctx.triggered_id tells us which specific delete button was clicked â specifically its index. This works because when the callback fires, Dash knows exactly which component triggered it.
- To find and remove the correct task, we need to:
- Get all delete button IDs using ctx.inputs_list[0] which contains the IDs of all components matching our pattern
- Map this to just get the ID dictionaries (each with a âtypeâ and âindexâ)
- Find the position (index) in our task list that matches the triggered buttonâs index
- Delete that task from current_tasks using del
As seen previously, the prevent_initial_call=True and if not any(n_clicks) check ensures we donât accidentally delete tasks when the app first loads or if the callback is triggered without a click.
4. Run the app
Letâs run the code again. We preferably place our callbacks before the app.run_server statement:
import uuidimport dash_mantine_components as dmcfrom dash import Dash, _dash_renderer, dcc, ctx, Input, Output, State, ALLfrom dash.exceptions import PreventUpdatefrom dash_iconify import DashIconify_dash_renderer._set_react_version("18.2.0")Â # for mantine# sample_list_data = ...## Layout functions:# get_task()# get_tasks_layout()# get_list_layoutapp = Dash(__name__)# app.layout = ..## Callbacks# add_task()# remove_task()# Start the serverif __name__ == '__main__':Â Â Â Â app.run_server(debug=True)
-> See the full code on Github or open the app.
You now have a fully working âyet basicâ todo app. Congratulations!
Conclusion
Letâs recap what we covered in this article:
- An introduction to DMC (Dash Mantine Components) and its components
- How to use pattern matching callbacks for dynamic interactions
- How to use CSS styling in a Dash context
In the next part, we will see how to adapt this code to handle multiple lists and keep the tasks saved after page reloads (persistence).
I hope you enjoyed this first part of the tutorial!Â
If you have any questions, please join us on the dedicated topic on Plotlyâs forum: here.