
Dmc Best Practices
- 7 installs
- 5 repo stars
- Updated August 5, 2026
- bjornmelin/dev-skills
dmc-best-practices is a skill that applies Dash Mantine Components best-practice rules when writing or refactoring Plotly Dash apps.
About
This skill gives best-practice rules for building Dash applications with Dash Mantine Components. Developers use it when writing, reviewing, or refactoring Dash apps to catch anti-patterns in callbacks, styling, theming, and performance. Rules are grouped by impact level and can be emitted as machine-readable UI audit findings.
- Best-practice rules for Dash Mantine Components apps organized by impact level
- Covers callbacks, styling, theming, performance, and accessibility
- Emits machine-readable ui_audit.v1 findings for DMC reviews
Dmc Best Practices by the numbers
- 7 all-time installs (skills.sh)
- Ranked #1,762 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
dmc-best-practices capabilities & compatibility
- Capabilities
- code review · frontend
- Use cases
- code review · refactoring · frontend
What dmc-best-practices says it does
Definitive best practices for building Dash applications with Dash Mantine Components. Rules are organized by impact level and category.
Wrap layout in MantineProvider** - All DMC components require it
Use dcc.Store for client data** - Not global variables
npx skills add https://github.com/bjornmelin/dev-skills --skill dmc-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 5, 2026 |
| Repository | bjornmelin/dev-skills ↗ |
What it does
Reviewing or refactoring a Plotly Dash app that uses Dash Mantine Components to apply callback, styling, and theming best practices.
Who is it for?
Developers writing or reviewing Plotly Dash dashboards with Dash Mantine Components.
Skip if: Non-Dash front-end frameworks or apps not using Dash Mantine Components.
When should I use this skill?
Writing, reviewing, or refactoring a Dash app that uses Dash Mantine Components.
By the numbers
- Top 10 critical rules
- 9 priority rule categories
Files
DMC Best Practices
Definitive best practices for building Dash applications with Dash Mantine Components. Rules are organized by impact level and category.
Priority Categories
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Architecture | CRITICAL | arch- | 4 |
| 2 | Callbacks | CRITICAL/HIGH | callback- | 6 |
| 3 | Styling | HIGH/MEDIUM | style- | 5 |
| 4 | Data Management | HIGH | data- | 4 |
| 5 | Performance | MEDIUM-HIGH | perf- | 3 |
| 6 | Forms & Validation | MEDIUM | form- | 2 |
| 7 | Theming | MEDIUM | theme- | 3 |
| 8 | DMC v2.x Migrations | MEDIUM | v2- | 2 |
| 9 | Accessibility | MEDIUM | a11y- | 1 |
Top 10 Critical Rules
1. Wrap layout in MantineProvider - All DMC components require it 2. Never modify global variables in callbacks - Breaks multi-worker deployments 3. Use State not Input for values that shouldn't trigger callbacks 4. Define callbacks before app.run() - Registration must happen first 5. Prevent circular callbacks - Outputs feeding inputs cause infinite loops 6. Return JSON-serializable values - Only dict, list, str, number, bool, None 7. Use static CSS selectors - Never target .m_* dynamic classes 8. Custom colors need 10 shades - Exactly 10 (0=lightest, 9=darkest) 9. Use dcc.Store for client data - Not global variables 10. Debounce text inputs - Limit callback firing on rapid changes
Rules Index
Architecture (CRITICAL)
- arch-mantine-provider - Wrap Layout in MantineProvider
- arch-callback-order - Define Callbacks Before app.run()
- arch-circular-callbacks - Prevent Circular Callbacks
- arch-global-variables - Never Modify Global Variables
Callbacks (CRITICAL/HIGH)
- callback-input-vs-state - Use State for Non-Triggering Values
- callback-json-serializable - Return JSON-Serializable Values
- callback-prevent-update - PreventUpdate vs no_update
- callback-debounce - Debounce Text Inputs
- callback-context - Use ctx.triggered_id Correctly
- callback-prevent-initial - Use prevent_initial_call Appropriately
Styling (HIGH/MEDIUM)
- style-static-selectors - Use Static Selectors Only
- style-props-limit - Limit Style Props to 3-4 Per Component
- style-responsive-css - Use CSS Media Queries for Responsive
- style-css-variables - Prefer CSS Variables Over Hardcoded
- style-classnames-over-styles - Use classNames Over styles Prop
Data Management (HIGH)
- data-dcc-store - Use dcc.Store for Client-Side Data
- data-server-caching - Use Server-Side Caching for Large Data
- data-session-isolation - Isolate Cache by Session ID
- data-signaling-pattern - Use Signaling Pattern for Expensive Ops
Performance (MEDIUM-HIGH)
- perf-clientside-callbacks - Use Clientside Callbacks for Frequent Updates
- perf-memoization - Memoize Expensive Functions
- perf-webgl-charts - Use WebGL for Large Scatter Plots
Forms & Validation (MEDIUM)
- form-validation-pattern - Validate Early, Fail Fast
- form-error-handling - Return User-Friendly Error Messages
Theming (MEDIUM)
- theme-custom-colors - Custom Colors Need 10 Shades
- theme-light-dark-mode - Test Both Light and Dark Modes
- theme-component-defaults - Set Component Defaults in Theme
DMC v2.x Migrations (MEDIUM)
- v2-breaking-changes - DMC v2.x Breaking Changes
- v2-notification-container - Use NotificationContainer Not Provider
Accessibility (MEDIUM)
- a11y-labels-required - Always Provide Labels for Inputs
Full Reference
See AGENTS.md for the complete compiled reference with all rules expanded.
UI Audit Contract
When DMC review output needs machine-readable evidence, shape findings as ui_audit.v1:
target.framework:dmcid:dmc.<rule-id>such asdmc.arch-mantine-providercategory: map DMC rule families deterministically:
architecture -> layout, callbacks -> state, styling -> layout, data -> state, performance -> performance, forms -> interaction, theming -> visual, migration -> migration, and accessibility -> accessibility
severity:CRITICALandHIGHrules becomeerror;MEDIUM-HIGHand
MEDIUM rules become warning; lower-risk notes become info
locations: repo-relative files or components when knowndocs: the relevant rule markdown path plus external component docs when the
rule cites them
Use observations for inventory facts such as detected DMC version, theme configuration, or callback counts that do not by themselves require a fix.
DMC Best Practices - Complete Reference
This document is optimized for AI agents and LLMs. It contains the complete, compiled reference for Dash Mantine Components best practices.
Table of Contents
- Wrap Layout in MantineProvider
- Define Callbacks Before app.run()
- Prevent Circular Callbacks
- Never Modify Global Variables
- Use State for Non-Triggering Values
- Return JSON-Serializable Values Only
- PreventUpdate vs no_update
- Debounce Text Inputs
- Use ctx.triggered_id Correctly
- Use prevent_initial_call Appropriately
- Use Static Selectors Only
- Limit Style Props to 3-4 Per Component
- Use CSS Media Queries for Responsive
- Prefer CSS Variables Over Hardcoded
- Use classNames Over styles Prop
- Use dcc.Store for Client-Side Data
- Use Server-Side Caching for Large Data
- Isolate Cache by Session ID
- Use Signaling Pattern for Expensive Ops
- Use Clientside Callbacks for Frequent Updates
- Memoize Expensive Functions
- Use WebGL for Large Scatter Plots
6. Forms & Validation (MEDIUM)
8. DMC v2.x Migrations (MEDIUM)
---
1. Architecture (CRITICAL)
Wrap Layout in MantineProvider
Impact: CRITICAL - App fails to render without it
All Dash Mantine Components require a MantineProvider wrapper at the root of your layout.
Incorrect:
app.layout = dmc.Container([
dmc.Title("My App"),
dmc.Button("Click me"),
])
# Error: MantineProvider is requiredCorrect:
app.layout = dmc.MantineProvider([
dmc.Container([
dmc.Title("My App"),
dmc.Button("Click me"),
])
])---
Define Callbacks Before app.run()
Impact: CRITICAL - Callbacks silently ignored if defined after run()
All callbacks must be registered before calling app.run().
Incorrect:
if __name__ == "__main__":
app.run(debug=True)
# Callback defined AFTER app.run() - NEVER REGISTERED
@callback(Output("output", "children"), Input("btn", "n_clicks"))
def update(n):
return f"Clicked {n} times"Correct:
@callback(Output("output", "children"), Input("btn", "n_clicks"))
def update(n):
return f"Clicked {n or 0} times"
if __name__ == "__main__":
app.run(debug=True)---
Prevent Circular Callbacks
Impact: CRITICAL - Causes infinite loops, crashes browser/server
When a callback output feeds back as an input, it creates an infinite loop.
Incorrect:
@callback(
Output("counter", "children"),
Input("counter", "children"), # Same as output - CIRCULAR
Input("btn", "n_clicks"),
)
def update(current, n):
return int(current or 0) + 1Correct:
@callback(
Output("counter", "children"),
Input("btn", "n_clicks"),
State("counter", "children"), # State reads without triggering
)
def update(n, current):
if not n:
return "0"
return str(int(current or 0) + 1)---
Never Modify Global Variables in Callbacks
Impact: CRITICAL - Breaks multi-worker deployments, causes data leaks
Each worker has its own copy of global state, causing inconsistent behavior.
Incorrect:
click_count = 0 # Global variable
@callback(Output("output", "children"), Input("btn", "n_clicks"))
def update(n):
global click_count
click_count += 1 # DANGEROUS
return f"Total: {click_count}"Correct:
# Use dcc.Store instead
dcc.Store(id="click-store", data={"count": 0})
@callback(
Output("click-store", "data"),
Output("output", "children"),
Input("btn", "n_clicks"),
State("click-store", "data"),
)
def update(n, store):
if not n:
return store, f"Total: {store['count']}"
new_count = store["count"] + 1
return {"count": new_count}, f"Total: {new_count}"---
2. Callbacks (CRITICAL/HIGH)
Use State for Non-Triggering Values
Impact: CRITICAL - Using Input when State needed causes unwanted executions
Input triggers the callback when its value changes. State reads without triggering.
Incorrect:
@callback(
Output("result", "children"),
Input("name", "value"), # Triggers on every keystroke
Input("email", "value"), # Also triggers
Input("submit", "n_clicks"),
)
def submit_form(name, email, n):
return process_form(name, email) # Runs hundreds of timesCorrect:
@callback(
Output("result", "children"),
Input("submit", "n_clicks"), # Only trigger
State("name", "value"), # Read without triggering
State("email", "value"),
prevent_initial_call=True,
)
def submit_form(n, name, email):
return process_form(name, email) # Runs only on submit---
Return JSON-Serializable Values Only
Impact: CRITICAL - Non-serializable returns crash callbacks silently
Callbacks must return: dict, list, str, int, float, bool, None.
Incorrect:
from datetime import datetime
@callback(Output("timestamp", "children"), Input("btn", "n_clicks"))
def update(n):
return datetime.now() # NOT serializableCorrect:
@callback(Output("timestamp", "children"), Input("btn", "n_clicks"))
def update(n):
return datetime.now().isoformat() # String is serializableConversions:
- datetime →
.isoformat() - Decimal →
float()orstr() - DataFrame →
.to_dict("records") - numpy array →
.tolist()
---
PreventUpdate vs no_update
Impact: HIGH - Wrong choice causes unnecessary updates or blocks needed ones
PreventUpdate blocks ALL outputs. no_update selectively skips specific outputs.
Incorrect:
from dash.exceptions import PreventUpdate
@callback(Output("status", "children"), Output("data", "children"), Input("btn", "n_clicks"))
def update(n):
if n % 2 == 0:
raise PreventUpdate # Blocks BOTH outputsCorrect:
from dash import no_update
@callback(Output("status", "children"), Output("data", "children"), Input("btn", "n_clicks"))
def update(n):
if n % 2 == 0:
return no_update, fetch_data() # Skip status, update data
return "Updated", fetch_data()---
Debounce Text Inputs
Impact: HIGH - Without debounce, callbacks fire on every keystroke
Incorrect:
dmc.TextInput(id="search")
@callback(Output("results", "children"), Input("search", "value"))
def search(query):
return api_search(query) # 5 API calls for typing "hello"Correct:
dmc.TextInput(id="search", debounce=300) # Wait 300ms
@callback(Output("results", "children"), Input("search", "value"))
def search(query):
return api_search(query) # 1 call after typing stops---
Use ctx.triggered_id Correctly
Impact: MEDIUM-HIGH - Determine which input fired the callback
Correct:
from dash import ctx
@callback(
Output("counter", "children"),
Input("btn-add", "n_clicks"),
Input("btn-subtract", "n_clicks"),
prevent_initial_call=True,
)
def update(add_clicks, sub_clicks):
triggered = ctx.triggered_id
if triggered == "btn-add":
return f"Added! Total: {add_clicks}"
elif triggered == "btn-subtract":
return f"Subtracted! Total: {sub_clicks}"---
Use prevent_initial_call Appropriately
Impact: MEDIUM - Skip unnecessary callback on page load
Correct for button-triggered actions:
@callback(
Output("result", "children"),
Input("submit-btn", "n_clicks"),
prevent_initial_call=True, # Skip on page load
)
def submit(n):
return f"Submitted {n} times"Don't use for initial data loading:
@callback(Output("chart", "figure"), Input("date-picker", "value"))
def update_chart(date):
# Should run on load for initial chart
return create_chart(date or default_date)---
3. Styling (HIGH/MEDIUM)
Use Static Selectors Only
Impact: CRITICAL - Dynamic class selectors break on library updates
Incorrect:
.m_77c9d27d { background-color: red; } /* Breaks on update */Correct:
.mantine-Button-root { background-color: red; } /* Stable */
.mantine-Button-root[data-disabled="true"] { opacity: 0.5; }---
Limit Style Props to 3-4 Per Component
Impact: HIGH - Excessive style props reduce readability
Incorrect:
dmc.Card(p="xl", m="md", w=400, h=300, bg="gray.1", c="dark.9", radius="lg", shadow="md")Correct:
dmc.Card(p="xl", radius="lg", className="feature-card").feature-card { width: 400px; height: 300px; /* ... */ }---
Use CSS Media Queries for Responsive Design
Impact: HIGH - More performant than responsive style props
Incorrect:
dmc.SimpleGrid(cols={"base": 1, "sm": 2, "md": 3, "lg": 4})Correct:
dmc.SimpleGrid(className="responsive-grid").responsive-grid { grid-template-columns: 1fr; }
@media (min-width: 48em) { .responsive-grid { grid-template-columns: repeat(2, 1fr); } }---
Prefer CSS Variables Over Hardcoded Values
Impact: MEDIUM - Maintains consistency and enables theme changes
Incorrect:
.my-card { background-color: #f8f9fa; padding: 16px; }Correct:
.my-card {
background-color: var(--mantine-color-gray-0);
padding: var(--mantine-spacing-md);
}---
Use classNames Over styles Prop
Impact: MEDIUM - Better maintainability and CSS specificity
Incorrect:
dmc.Button("Submit", styles={"root": {"minWidth": "200px"}})Correct:
dmc.Button("Submit", classNames={"root": "submit-button"}).submit-button { min-width: 200px; }
.submit-button:hover { background-color: var(--mantine-color-green-7); }---
4. Data Management (HIGH)
Use dcc.Store for Client-Side Data
Impact: HIGH - Proper data sharing between callbacks
Correct:
dcc.Store(id="shared-store", data={"value": None})
@callback(Output("shared-store", "data"), Input("btn", "n_clicks"), State("shared-store", "data"))
def update(n, store):
store["value"] = n
return store
@callback(Output("display", "children"), Input("shared-store", "modified_timestamp"), State("shared-store", "data"))
def display(ts, store):
return f"Value: {store.get('value')}"---
Use Server-Side Caching for Large Data
Impact: HIGH - Handles datasets too large for client-side
Correct:
from flask_caching import Cache
cache = Cache(app.server, config={"CACHE_TYPE": "filesystem", "CACHE_DIR": ".cache"})
@cache.memoize()
def get_large_dataframe():
return pd.read_csv("large_file.csv")
@callback(Output("chart", "figure"), Input("filter", "value"))
def update(filter_val):
df = get_large_dataframe() # Cached
return create_figure(df[df["category"] == filter_val])---
Isolate Cache by Session ID
Impact: HIGH - Required for multi-user deployments
Correct:
def get_session_id():
if "session_id" not in session:
session["session_id"] = str(uuid.uuid4())
return session["session_id"]
def get_user_data(session_id, filters):
cache_key = f"data_{session_id}_{hash(tuple(filters))}"
# Each user's data isolated---
Use Signaling Pattern for Expensive Operations
Impact: MEDIUM-HIGH - Compute once, retrieve cached results
Correct:
dcc.Store(id="data-signal")
@callback(Output("data-signal", "data"), Input("filters", "value"))
def compute(filters):
data = expensive_query(filters)
cache.set(f"data_{hash(tuple(filters))}", data)
return {"cache_key": f"data_{hash(tuple(filters))}"}
@callback(Output("chart", "figure"), Input("data-signal", "data"))
def update_chart(signal):
data = cache.get(signal["cache_key"])
return create_chart(data)---
5. Performance (MEDIUM-HIGH)
Use Clientside Callbacks for Frequent Updates
Impact: HIGH - Eliminates server round-trips
Correct:
clientside_callback(
"""
function(n_clicks) {
if (!n_clicks) return window.dash_clientside.no_update;
const current = document.documentElement.getAttribute('data-mantine-color-scheme');
return current === 'light' ? 'dark' : 'light';
}
""",
Output("mantine-provider", "forceColorScheme"),
Input("theme-toggle", "n_clicks"),
)---
Memoize Expensive Functions
Impact: MEDIUM-HIGH - Cache repeated computations
Correct:
from functools import lru_cache
@lru_cache(maxsize=128)
def process_data(category, year):
df = pd.read_csv("large_file.csv")
return df[(df["category"] == category) & (df["year"] == year)].to_dict()---
Use WebGL for Large Scatter Plots
Impact: MEDIUM - Required for 100k+ data points
Correct:
import plotly.express as px
fig = px.scatter(df, x="x", y="y", render_mode="webgl")
# Or use go.Scattergl instead of go.Scatter---
6. Forms & Validation (MEDIUM)
Validate Early, Fail Fast
Impact: MEDIUM - Collect all errors, show clear feedback
Correct:
@callback(
Output("result", "children"),
Output("email", "error"),
Output("password", "error"),
Input("submit", "n_clicks"),
State("email", "value"),
State("password", "value"),
prevent_initial_call=True,
)
def submit(n, email, password):
errors = []
email_error = password_error = ""
if not email or "@" not in email:
email_error = "Valid email required"
errors.append(email_error)
if not password or len(password) < 8:
password_error = "Password must be 8+ characters"
errors.append(password_error)
if errors:
return dmc.Alert(children=errors, color="red"), email_error, password_error
return dmc.Alert("Success!", color="green"), "", ""---
Return User-Friendly Error Messages
Impact: MEDIUM - Log technical details, show helpful messages
Correct:
import logging
logger = logging.getLogger(__name__)
@callback(Output("result", "children"), Input("btn", "n_clicks"), prevent_initial_call=True)
def process(n):
try:
return dmc.Alert(f"Success: {process_data()}", color="green")
except ConnectionError as e:
logger.error(f"API failed: {e}", exc_info=True)
return dmc.Alert("Unable to connect. Try again later.", color="red")---
7. Theming (MEDIUM)
Custom Colors Need 10 Shades
Impact: HIGH - Incomplete palettes cause runtime errors
Correct:
theme = {
"colors": {
"brand": [
"#E3F2FD", "#BBDEFB", "#90CAF9", "#64B5F6", "#42A5F5",
"#2196F3", "#1E88E5", "#1976D2", "#1565C0", "#0D47A1",
], # Exactly 10 shades (0-9)
},
"primaryColor": "brand",
}---
Test Both Light and Dark Modes
Impact: MEDIUM - Ensures consistent appearance
Correct:
dmc.MantineProvider(id="mantine-provider", defaultColorScheme="light", children=[...])
# CSS for both modes:
# [data-mantine-color-scheme="dark"] .my-component { ... }---
Set Component Defaults in Theme
Impact: MEDIUM - Centralize styling for consistency
Correct:
theme = {
"components": {
"Button": {"defaultProps": {"size": "md", "radius": "md"}},
"TextInput": {"defaultProps": {"size": "md", "radius": "sm"}},
},
}
# Now all Buttons get size="md" by default---
8. DMC v2.x Migrations (MEDIUM)
DMC v2.x Breaking Changes
Key changes from v1.x to v2.x:
| Change | v1.x | v2.x |
|---|---|---|
| DateTimePicker | timeInputProps={} | timePickerProps={} |
| Carousel | loop=True | emblaOptions={"loop": True} |
| Image | flex: 0 default | Add flex=0 explicitly |
| DatesProvider | timezone supported | timezone removed |
| Popover.hideDetached | False | True |
---
Use NotificationContainer Not NotificationProvider
Impact: MEDIUM - NotificationProvider is deprecated
Incorrect:
dmc.NotificationProvider(position="top-right") # DEPRECATEDCorrect:
dmc.NotificationContainer(position="top-right") # v2.x---
9. Accessibility (MEDIUM)
Always Provide Labels for Form Inputs
Impact: MEDIUM - Required for screen readers
Incorrect:
dmc.TextInput(id="email", placeholder="Enter email") # No labelCorrect:
dmc.TextInput(
id="email",
label="Email Address",
placeholder="Enter your email",
description="We'll never share your email",
required=True,
)---
Quick Reference: Top 10 Rules
1. Wrap layout in MantineProvider - All DMC components require it 2. Never modify global variables in callbacks - Breaks multi-worker deployments 3. Use State not Input for values that shouldn't trigger callbacks 4. Define callbacks before app.run() - Registration must happen first 5. Prevent circular callbacks - Outputs feeding inputs cause infinite loops 6. Return JSON-serializable values - Only dict, list, str, number, bool, None 7. Use static CSS selectors - Never target .m_* dynamic classes 8. Custom colors need 10 shades - Exactly 10 (0=lightest, 9=darkest) 9. Use dcc.Store for client data - Not global variables 10. Debounce text inputs - Limit callback firing on rapid changes
Always Provide Labels for Form Inputs
All form inputs must have a label for accessibility. Screen readers rely on labels to announce input purposes to users.
Incorrect (missing labels):
import dash_mantine_components as dmc
# No label - screen reader can't identify this input
dmc.TextInput(
id="email",
placeholder="Enter email", # Placeholder is NOT a label
)
dmc.Select(
id="country",
data=["USA", "Canada", "UK"],
placeholder="Select country", # Still no label
)
# Screen reader announces: "Edit text" - unhelpfulCorrect (with labels):
import dash_mantine_components as dmc
dmc.TextInput(
id="email",
label="Email Address", # Announces: "Email Address, edit text"
placeholder="Enter your email",
description="We'll never share your email",
required=True,
)
dmc.Select(
id="country",
label="Country", # Announces: "Country, combo box"
data=["USA", "Canada", "UK"],
placeholder="Select your country",
)Hidden labels (when visual label not needed):
# Search input where icon indicates purpose
dmc.TextInput(
id="search",
label="Search", # Label for screen readers
labelProps={"style": {"display": "none"}}, # Visually hidden
leftSection=DashIconify(icon="tabler:search"),
placeholder="Search...",
)Alternative: aria-label:
# When you can't use the label prop
dmc.TextInput(
id="search",
placeholder="Search...",
inputProps={"aria-label": "Search the site"},
)Form input accessibility checklist:
| Prop | Purpose | Required |
|---|---|---|
label | Primary label text | Yes |
description | Additional help text | Recommended |
error | Error message | When invalid |
required | Marks as required | When mandatory |
disabled | Marks as disabled | When inactive |
Required fields:
dmc.TextInput(
label="Username",
required=True, # Shows asterisk, announces "required"
)Error messages:
dmc.TextInput(
label="Email",
error="Please enter a valid email address", # Announced by screen reader
)Grouped inputs:
dmc.Fieldset(
legend="Contact Information", # Groups related inputs
children=[
dmc.TextInput(label="First Name"),
dmc.TextInput(label="Last Name"),
dmc.TextInput(label="Email"),
],
)Focus management:
# Default focusRing="auto" is accessible
# Never use focusRing="never" - removes focus indicator
dmc.MantineProvider(
theme={"focusRing": "auto"}, # Shows focus ring on keyboard nav
children=[...]
)Reference: https://mantine.dev/core/text-input/#accessibility
Define Callbacks Before app.run()
All callbacks must be registered before calling app.run(). Callbacks defined after the server starts will be silently ignored.
Incorrect (callback after app.run()):
from dash import Dash, Input, Output, callback
import dash_mantine_components as dmc
app = Dash(__name__)
app.layout = dmc.MantineProvider([
dmc.Button("Click", id="btn"),
dmc.Text(id="output"),
])
if __name__ == "__main__":
app.run(debug=True) # Server starts here
# This callback is NEVER registered - defined too late
@callback(Output("output", "children"), Input("btn", "n_clicks"))
def update(n):
return f"Clicked {n} times"Correct (callback before app.run()):
from dash import Dash, Input, Output, callback
import dash_mantine_components as dmc
app = Dash(__name__)
app.layout = dmc.MantineProvider([
dmc.Button("Click", id="btn"),
dmc.Text(id="output"),
])
# Callback registered BEFORE app.run()
@callback(Output("output", "children"), Input("btn", "n_clicks"))
def update(n):
return f"Clicked {n or 0} times"
if __name__ == "__main__":
app.run(debug=True) # Server starts after callbacks registeredMulti-file pattern:
# app.py
from dash import Dash
from layouts import layout
from callbacks import register_callbacks # Import callback registration
app = Dash(__name__)
app.layout = layout
register_callbacks(app) # Register all callbacks
if __name__ == "__main__":
app.run(debug=True)Reference: https://dash.plotly.com/basic-callbacks
Prevent Circular Callbacks
When a callback output feeds back as an input to the same callback (directly or through a chain), it creates an infinite loop that crashes the browser or server.
Incorrect (direct circular reference):
from dash import Input, Output, callback
import dash_mantine_components as dmc
# INFINITE LOOP: counter output feeds back as input
@callback(
Output("counter", "children"),
Input("counter", "children"), # Same as output - CIRCULAR
Input("btn", "n_clicks"),
)
def update(current, n):
return int(current or 0) + 1
# Browser freezes, server overwhelmedIncorrect (indirect circular chain):
# Callback A: input-a -> output-b
@callback(Output("output-b", "children"), Input("input-a", "value"))
def callback_a(val):
return val
# Callback B: output-b -> input-a (CIRCULAR CHAIN)
@callback(Output("input-a", "value"), Input("output-b", "children"))
def callback_b(val):
return val
# A triggers B, B triggers A, infinite loopCorrect (use State for non-triggering values):
from dash import Input, Output, State, callback
import dash_mantine_components as dmc
@callback(
Output("counter", "children"),
Input("btn", "n_clicks"),
State("counter", "children"), # State reads without triggering
)
def update(n, current):
if not n:
return "0"
return str(int(current or 0) + 1)Correct (use dcc.Store for shared state):
from dash import Input, Output, State, callback, dcc
import dash_mantine_components as dmc
# Layout includes store
dcc.Store(id="shared-data", data={"count": 0})
# Callback 1: updates store
@callback(Output("shared-data", "data"), Input("btn", "n_clicks"))
def update_store(n):
return {"count": n or 0}
# Callback 2: reads store (no circular dependency)
@callback(Output("display", "children"), Input("shared-data", "data"))
def display_count(data):
return f"Count: {data['count']}"Reference: https://dash.plotly.com/advanced-callbacks
Never Modify Global Variables in Callbacks
Modifying global variables in callbacks works in development but breaks in production with multiple workers. Each worker has its own copy of global state, causing inconsistent behavior and potential data leaks between users.
Incorrect (modifying global variable):
from dash import Dash, Input, Output, callback
import dash_mantine_components as dmc
app = Dash(__name__)
# Global variable - DANGEROUS
click_count = 0
@callback(Output("output", "children"), Input("btn", "n_clicks"))
def update(n):
global click_count
click_count += 1 # Modifies global state
return f"Total clicks: {click_count}"
# In production with 4 workers:
# - User A clicks: worker 1 shows 1
# - User B clicks: worker 2 shows 1 (different worker, different state)
# - User A clicks: worker 3 shows 1 (yet another worker)
# - Data inconsistent, users may see each other's dataCorrect (use dcc.Store for client-side state):
from dash import Dash, Input, Output, State, callback, dcc
import dash_mantine_components as dmc
app = Dash(__name__)
app.layout = dmc.MantineProvider([
dcc.Store(id="click-store", data={"count": 0}), # Per-user state
dmc.Button("Click", id="btn"),
dmc.Text(id="output"),
])
@callback(
Output("click-store", "data"),
Output("output", "children"),
Input("btn", "n_clicks"),
State("click-store", "data"),
)
def update(n, store):
if not n:
return store, f"Total: {store['count']}"
new_count = store["count"] + 1
return {"count": new_count}, f"Total: {new_count}"
# Each user has their own store, isolated from othersCorrect (use server-side caching with session isolation):
from flask_caching import Cache
import uuid
cache = Cache(config={"CACHE_TYPE": "redis"})
def get_session_id():
# Get or create session-specific ID
if "session_id" not in flask.session:
flask.session["session_id"] = str(uuid.uuid4())
return flask.session["session_id"]
@callback(Output("output", "children"), Input("btn", "n_clicks"))
def update(n):
session_id = get_session_id()
cache_key = f"clicks_{session_id}" # Session-isolated key
count = cache.get(cache_key) or 0
count += 1
cache.set(cache_key, count)
return f"Total: {count}"Reference: https://dash.plotly.com/sharing-data-between-callbacks
Wrap Layout in MantineProvider
All Dash Mantine Components require a MantineProvider wrapper at the root of your layout. Without it, components will fail to render or display incorrect styles.
Incorrect (missing MantineProvider):
from dash import Dash
import dash_mantine_components as dmc
app = Dash(__name__)
# Components placed directly in layout - WILL FAIL
app.layout = dmc.Container([
dmc.Title("My App"),
dmc.Button("Click me"),
])
# Error: MantineProvider is requiredCorrect (wrapped in MantineProvider):
from dash import Dash
import dash_mantine_components as dmc
app = Dash(__name__)
# All components wrapped in MantineProvider
app.layout = dmc.MantineProvider([
dmc.Container([
dmc.Title("My App"),
dmc.Button("Click me"),
])
])With theme configuration:
app.layout = dmc.MantineProvider(
id="mantine-provider",
children=[
dmc.Container([...])
],
theme={
"primaryColor": "blue",
"fontFamily": "Inter, sans-serif",
},
defaultColorScheme="light",
)Reference: https://www.dash-mantine-components.com/components/mantineprovider
Use ctx.triggered_id Correctly
When a callback has multiple inputs, use ctx.triggered_id to determine which input triggered the callback. This enables different behavior based on the trigger source.
Incorrect (not checking trigger source):
from dash import Input, Output, callback
@callback(
Output("output", "children"),
Input("btn-add", "n_clicks"),
Input("btn-subtract", "n_clicks"),
)
def update(add_clicks, sub_clicks):
# Can't tell which button was clicked
# Both inputs provided, unclear which triggered
return f"Add: {add_clicks}, Sub: {sub_clicks}"Correct (using ctx.triggered_id):
from dash import Input, Output, callback, ctx
@callback(
Output("counter", "children"),
Input("btn-add", "n_clicks"),
Input("btn-subtract", "n_clicks"),
prevent_initial_call=True,
)
def update(add_clicks, sub_clicks):
triggered = ctx.triggered_id
if triggered == "btn-add":
return f"Added! Total adds: {add_clicks}"
elif triggered == "btn-subtract":
return f"Subtracted! Total subs: {sub_clicks}"
return "Unknown trigger"Handle initial load:
@callback(
Output("result", "children"),
Input("btn-1", "n_clicks"),
Input("btn-2", "n_clicks"),
)
def update(n1, n2):
# On initial load, ctx.triggered is empty list
if not ctx.triggered:
return "Click a button"
triggered = ctx.triggered_id
# Now handle the actual triggerPattern-matching callbacks:
from dash import Input, Output, callback, ctx, ALL
@callback(
Output("output", "children"),
Input({"type": "item-btn", "index": ALL}, "n_clicks"),
)
def update(clicks):
if not ctx.triggered:
return "No item selected"
# triggered_id is a dict for pattern-matching
triggered = ctx.triggered_id # {"type": "item-btn", "index": 3}
item_index = triggered["index"]
return f"Item {item_index} clicked"Multiple properties from same component:
@callback(
Output("result", "children"),
Input("input", "value"),
Input("input", "n_blur"), # Same component, different prop
)
def update(value, n_blur):
# ctx.triggered_prop_ids gives full info
# Returns dict like {"input.value": value} or {"input.n_blur": n_blur}
prop_id = list(ctx.triggered_prop_ids.keys())[0]
if ".value" in prop_id:
return f"Value changed: {value}"
elif ".n_blur" in prop_id:
return f"Input blurred with value: {value}"ctx attributes:
| Attribute | Description |
|---|---|
ctx.triggered_id | ID of component that triggered (str or dict) |
ctx.triggered | List of triggered props with values |
ctx.triggered_prop_ids | Dict of triggered prop IDs to values |
ctx.inputs | All input values |
ctx.states | All state values |
Reference: https://dash.plotly.com/advanced-callbacks#determining-which-input-changed
Debounce Text Inputs
Text inputs trigger callbacks on every keystroke by default. Use debounce to limit callback frequency and reduce server load.
Incorrect (no debounce):
from dash import Input, Output, callback
import dash_mantine_components as dmc
dmc.TextInput(id="search", placeholder="Search...")
@callback(Output("results", "children"), Input("search", "value"))
def search(query):
# Fires on EVERY keystroke: "h", "he", "hel", "hell", "hello"
# 5 API calls for typing "hello"
return api_search(query)Correct (with debounce):
from dash import Input, Output, callback
import dash_mantine_components as dmc
dmc.TextInput(
id="search",
placeholder="Search...",
debounce=300, # Wait 300ms after typing stops
)
@callback(Output("results", "children"), Input("search", "value"))
def search(query):
# Only fires 300ms after user stops typing
# 1 API call for typing "hello"
return api_search(query)Textarea with debounce:
dmc.Textarea(
id="content",
debounce=500, # 500ms for longer text input
minRows=4,
)Debounce values by use case:
| Use Case | Debounce (ms) |
|---|---|
| Search/autocomplete | 200-300 |
| Form validation | 300-500 |
| Long text editing | 500-1000 |
| Real-time preview | 100-200 |
Alternative: Submit button pattern:
# No debounce needed - only fires on button click
dmc.TextInput(id="search", placeholder="Search...")
dmc.Button("Search", id="search-btn")
@callback(
Output("results", "children"),
Input("search-btn", "n_clicks"),
State("search", "value"), # State, not Input
prevent_initial_call=True,
)
def search(n, query):
return api_search(query)NumberInput debounce:
dmc.NumberInput(
id="quantity",
debounce=200,
min=0,
max=100,
)Reference: https://www.dash-mantine-components.com/components/textinput
Use State for Non-Triggering Values
Input triggers the callback when its value changes. State reads the current value without triggering. Using Input when you should use State causes unnecessary callback executions and can create circular dependencies.
Incorrect (Input for form values):
from dash import Input, Output, callback
import dash_mantine_components as dmc
# PROBLEM: Callback fires on EVERY keystroke in BOTH inputs
@callback(
Output("result", "children"),
Input("name", "value"), # Triggers on every keystroke
Input("email", "value"), # Also triggers on every keystroke
Input("submit", "n_clicks"),
)
def submit_form(name, email, n):
# This runs hundreds of times while user types
return process_form(name, email)Correct (State for form values, Input for submit):
from dash import Input, Output, State, callback
import dash_mantine_components as dmc
# Only fires when submit button clicked
@callback(
Output("result", "children"),
Input("submit", "n_clicks"), # Only trigger
State("name", "value"), # Read without triggering
State("email", "value"), # Read without triggering
prevent_initial_call=True,
)
def submit_form(n, name, email):
# Only runs when user clicks submit
return process_form(name, email)When to use Input vs State:
| Scenario | Use |
|---|---|
| Button clicks | Input("btn", "n_clicks") |
| Dropdown selection that should update immediately | Input("dropdown", "value") |
| Form fields submitted via button | State("input", "value") |
| Reading current value without triggering | State("component", "prop") |
| Slider that updates chart in real-time | Input("slider", "value") |
| Slider value read only on button click | State("slider", "value") |
Combined pattern:
@callback(
Output("chart", "figure"),
Input("update-btn", "n_clicks"), # Trigger: button click
Input("auto-refresh", "checked"), # Trigger: toggle change
State("date-range", "value"), # Read: date selection
State("filters", "value"), # Read: filter settings
prevent_initial_call=True,
)
def update_chart(n, auto_refresh, dates, filters):
# Only runs on button click or auto-refresh toggle
return build_chart(dates, filters)Reference: https://dash.plotly.com/basic-callbacks#state
Return JSON-Serializable Values Only
Callbacks must return JSON-serializable values: dict, list, str, int, float, bool, None. Returning Python objects like datetime, Decimal, or custom classes causes silent failures.
Incorrect (returning datetime):
from dash import Input, Output, callback
from datetime import datetime
@callback(Output("timestamp", "children"), Input("btn", "n_clicks"))
def update(n):
return datetime.now() # NOT JSON-serializable
# Callback fails silently, output never updatesCorrect (convert to string):
from dash import Input, Output, callback
from datetime import datetime
@callback(Output("timestamp", "children"), Input("btn", "n_clicks"))
def update(n):
return datetime.now().isoformat() # String is serializableIncorrect (returning Decimal):
from decimal import Decimal
@callback(Output("price", "children"), Input("qty", "value"))
def calculate(qty):
price = Decimal("19.99") * qty
return price # Decimal not serializableCorrect (convert Decimal to float or string):
from decimal import Decimal
@callback(Output("price", "children"), Input("qty", "value"))
def calculate(qty):
price = Decimal("19.99") * qty
return float(price) # Or str(price) for precisionIncorrect (returning pandas DataFrame):
import pandas as pd
@callback(Output("store", "data"), Input("btn", "n_clicks"))
def fetch_data(n):
df = pd.read_csv("data.csv")
return df # DataFrame not serializableCorrect (convert DataFrame to dict):
import pandas as pd
@callback(Output("store", "data"), Input("btn", "n_clicks"))
def fetch_data(n):
df = pd.read_csv("data.csv")
return df.to_dict("records") # List of dicts is serializableSerializable types:
| Type | Example |
|---|---|
| dict | {"key": "value"} |
| list | [1, 2, 3] |
| str | "hello" |
| int | 42 |
| float | 3.14 |
| bool | True / False |
| None | None |
Non-serializable (convert first):
| Type | Conversion |
|---|---|
| datetime | .isoformat() |
| Decimal | float() or str() |
| DataFrame | .to_dict("records") |
| numpy array | .tolist() |
| set | list() |
| custom class | asdict() or .__dict__ |
Reference: https://dash.plotly.com/basic-callbacks
Use prevent_initial_call Appropriately
prevent_initial_call=True skips callback execution on page load. Use it when the callback should only run in response to user interaction, not on initial render.
Incorrect (runs on page load unnecessarily):
from dash import Input, Output, callback
@callback(
Output("result", "children"),
Input("submit-btn", "n_clicks"),
)
def submit(n):
# Runs on page load with n=None
# May cause errors or show unwanted "submitted" message
return f"Form submitted {n} times"
# Shows "Form submitted None times" on loadCorrect (prevent initial call):
from dash import Input, Output, callback
@callback(
Output("result", "children"),
Input("submit-btn", "n_clicks"),
prevent_initial_call=True, # Skip on page load
)
def submit(n):
# Only runs when button actually clicked
return f"Form submitted {n} times"
# Shows nothing until user clicksWhen to use prevent_initial_call:
| Scenario | Use prevent_initial_call |
|---|---|
| Form submission | Yes |
| Delete/reset actions | Yes |
| Button-triggered operations | Yes |
| Initial data display | No |
| Default selections | No |
| Page initialization | No |
Without prevent_initial_call (data loading):
@callback(
Output("chart", "figure"),
Input("date-picker", "value"),
)
def update_chart(date):
# Should run on load to show initial chart
# Don't use prevent_initial_call here
return create_chart(date or default_date)Required with allow_duplicate:
from dash import Input, Output, callback
# First callback (normal)
@callback(Output("output", "children"), Input("btn-1", "n_clicks"))
def callback1(n):
return f"Button 1: {n}"
# Second callback targeting same output
@callback(
Output("output", "children", allow_duplicate=True),
Input("btn-2", "n_clicks"),
prevent_initial_call=True, # REQUIRED with allow_duplicate
)
def callback2(n):
return f"Button 2: {n}"Handle None values even with prevent_initial_call:
@callback(
Output("result", "children"),
Input("btn", "n_clicks"),
prevent_initial_call=True,
)
def update(n):
# Still good practice to handle None
# In case callback is somehow triggered without click
if not n:
return ""
return f"Clicked {n} times"Reference: https://dash.plotly.com/advanced-callbacks#prevent-initial-callback
PreventUpdate vs no_update
PreventUpdate blocks ALL outputs from updating. no_update selectively skips specific outputs while updating others. Use the right one for your use case.
Incorrect (PreventUpdate when only some outputs should skip):
from dash import Input, Output, callback
from dash.exceptions import PreventUpdate
@callback(
Output("status", "children"),
Output("data", "children"),
Input("btn", "n_clicks"),
)
def update(n):
if not n:
raise PreventUpdate # Blocks BOTH outputs
if n % 2 == 0:
raise PreventUpdate # Wanted to skip status, but blocks data too
return "Updated", fetch_data()Correct (no_update for selective skipping):
from dash import Input, Output, callback, no_update
@callback(
Output("status", "children"),
Output("data", "children"),
Input("btn", "n_clicks"),
)
def update(n):
if not n:
return no_update, no_update # Skip both initially
if n % 2 == 0:
return no_update, fetch_data() # Skip status, update data
return "Updated", fetch_data() # Update bothWhen to use PreventUpdate:
from dash.exceptions import PreventUpdate
@callback(Output("result", "children"), Input("btn", "n_clicks"))
def update(n):
# Use PreventUpdate when callback should do nothing at all
if not n:
raise PreventUpdate
return process()When to use no_update:
from dash import no_update
@callback(
Output("chart", "figure"),
Output("error", "children"),
Input("submit", "n_clicks"),
State("data", "value"),
)
def update(n, data):
if not n:
return no_update, no_update
try:
return create_chart(data), no_update # Update chart, keep error clear
except Exception as e:
return no_update, str(e) # Keep chart, show errorSummary:
| Scenario | Use |
|---|---|
| Skip callback entirely | raise PreventUpdate |
| Skip all outputs but cleaner syntax | return no_update, no_update, ... |
| Update some outputs, skip others | return value, no_update, value |
| Conditional output selection | no_update |
Reference: https://dash.plotly.com/advanced-callbacks#prevent-callback-execution
Use dcc.Store for Client-Side Data
dcc.Store provides client-side data storage for sharing data between callbacks. It's the correct way to maintain state without global variables.
Incorrect (global variable for shared state):
# Global variable - DANGEROUS
shared_data = {}
@callback(Output("output1", "children"), Input("btn1", "n_clicks"))
def update1(n):
shared_data["value"] = n # Modifies global
return f"Set: {n}"
@callback(Output("output2", "children"), Input("btn2", "n_clicks"))
def update2(n):
return f"Got: {shared_data.get('value')}" # Reads global
# Breaks in production with multiple workersCorrect (dcc.Store for shared state):
from dash import dcc, Input, Output, State, callback
# Add Store to layout
dcc.Store(id="shared-store", data={"value": None})
@callback(
Output("shared-store", "data"),
Output("output1", "children"),
Input("btn1", "n_clicks"),
State("shared-store", "data"),
)
def update1(n, store):
store["value"] = n
return store, f"Set: {n}"
@callback(
Output("output2", "children"),
Input("shared-store", "modified_timestamp"), # Trigger on store change
State("shared-store", "data"),
)
def update2(ts, store):
return f"Got: {store.get('value')}"Storage types:
# Memory - cleared on page refresh (default)
dcc.Store(id="memory-store", storage_type="memory")
# Session - cleared when browser tab closes
dcc.Store(id="session-store", storage_type="session")
# Local - persists across sessions (2MB limit)
dcc.Store(id="local-store", storage_type="local")Pattern: modified_timestamp as Input:
# Use modified_timestamp to trigger when store updates
@callback(
Output("display", "children"),
Input("data-store", "modified_timestamp"), # Triggers on ANY change
State("data-store", "data"), # Read actual data
)
def display_data(ts, data):
if not data:
return "No data"
return format_data(data)Store size limits:
- ~2MB in most browsers
- Use server-side caching for larger datasets
- Consider compression for medium datasets
import json
import gzip
import base64
def compress_data(data):
json_str = json.dumps(data)
compressed = gzip.compress(json_str.encode())
return base64.b64encode(compressed).decode()
def decompress_data(compressed):
decoded = base64.b64decode(compressed)
decompressed = gzip.decompress(decoded)
return json.loads(decompressed)Reference: https://dash.plotly.com/dash-core-components/store
Use Server-Side Caching for Large Data
For datasets larger than 2MB or expensive computations, use server-side caching with Flask-Caching. Redis is recommended for production multi-worker deployments.
Incorrect (large data in dcc.Store):
import pandas as pd
from dash import dcc, callback, Output, Input
# 50MB DataFrame - TOO LARGE for client storage
@callback(Output("store", "data"), Input("load-btn", "n_clicks"))
def load_data(n):
df = pd.read_csv("large_file.csv") # 50MB
return df.to_dict("records") # Exceeds 2MB limit, fails silentlyCorrect (server-side caching):
from flask_caching import Cache
from dash import Dash, callback, Output, Input
import pandas as pd
app = Dash(__name__)
# Configure cache (filesystem for development)
cache = Cache(app.server, config={
"CACHE_TYPE": "filesystem",
"CACHE_DIR": ".cache",
"CACHE_DEFAULT_TIMEOUT": 300,
})
@cache.memoize()
def get_large_dataframe():
"""Cached data loading - runs once, result cached."""
return pd.read_csv("large_file.csv")
@callback(Output("chart", "figure"), Input("filter", "value"))
def update_chart(filter_val):
df = get_large_dataframe() # Returns cached data
filtered = df[df["category"] == filter_val]
return create_figure(filtered)Production setup (Redis):
cache = Cache(app.server, config={
"CACHE_TYPE": "redis",
"CACHE_REDIS_URL": "redis://localhost:6379/0",
"CACHE_DEFAULT_TIMEOUT": 3600,
})Cache with parameters:
@cache.memoize()
def get_filtered_data(category, date_range):
"""Cache key includes all parameters."""
df = pd.read_csv("data.csv")
return df[
(df["category"] == category) &
(df["date"].between(*date_range))
]
@callback(Output("results", "children"), Input("category", "value"), Input("dates", "value"))
def update(category, dates):
# Each unique (category, dates) combination cached separately
data = get_filtered_data(category, tuple(dates))
return format_results(data)Clear cache programmatically:
# Clear specific cached function
cache.delete_memoized(get_large_dataframe)
# Clear all cache
cache.clear()Background data loading:
from dash.long_callback import DiskcacheLongCallbackManager
import diskcache
cache = diskcache.Cache(".cache")
long_callback_manager = DiskcacheLongCallbackManager(cache)
@app.long_callback(
Output("results", "children"),
Input("load-btn", "n_clicks"),
manager=long_callback_manager,
running=[(Output("load-btn", "disabled"), True, False)],
)
def load_expensive_data(n):
# Runs in background, doesn't block server
return process_large_dataset()Reference: https://dash.plotly.com/sharing-data-between-callbacks
Isolate Cache by Session ID
In multi-user applications, cache keys must be isolated by session to prevent data leaks between users. Without isolation, User A may see User B's cached data.
Incorrect (shared cache key):
from flask_caching import Cache
cache = Cache(config={"CACHE_TYPE": "redis"})
@cache.memoize()
def get_user_dashboard():
"""DANGEROUS: Same cache key for all users."""
return fetch_dashboard_data()
@callback(Output("dashboard", "children"), Input("refresh", "n_clicks"))
def refresh(n):
return get_user_dashboard() # User A sees User B's dashboard!Correct (session-isolated cache key):
from flask_caching import Cache
from flask import session
import uuid
cache = Cache(config={"CACHE_TYPE": "redis"})
def get_session_id():
"""Get or create session-specific identifier."""
if "session_id" not in session:
session["session_id"] = str(uuid.uuid4())
return session["session_id"]
def get_user_dashboard(session_id):
"""Session ID as parameter creates unique cache key."""
cache_key = f"dashboard_{session_id}"
cached = cache.get(cache_key)
if cached:
return cached
data = fetch_dashboard_data()
cache.set(cache_key, data, timeout=300)
return data
@callback(Output("dashboard", "children"), Input("refresh", "n_clicks"))
def refresh(n):
session_id = get_session_id()
return get_user_dashboard(session_id) # Each user gets own cacheUsing memoize with session:
@cache.memoize()
def get_user_data(session_id, filters):
"""Session ID as first param ensures isolation."""
# Cache key: get_user_data(session_id, filters)
return query_database(filters)
@callback(Output("data", "children"), Input("filters", "value"))
def update(filters):
session_id = get_session_id()
# Each user's filters cached separately
return get_user_data(session_id, tuple(filters))Alternative: User ID from authentication:
from flask_login import current_user
def get_user_id():
"""Use authenticated user ID if available."""
if current_user.is_authenticated:
return str(current_user.id)
return get_session_id() # Fallback for anonymous users
@cache.memoize()
def get_personalized_data(user_id):
return fetch_user_specific_data(user_id)Clear user-specific cache:
def clear_user_cache(session_id):
"""Clear all cache entries for a specific user."""
# With Redis, use pattern matching
pattern = f"*_{session_id}*"
for key in cache.cache._read_client.keys(pattern):
cache.delete(key)Enable Flask sessions:
from dash import Dash
import os
app = Dash(__name__)
app.server.secret_key = os.environ.get("SECRET_KEY", os.urandom(24))Reference: https://dash.plotly.com/sharing-data-between-callbacks#example-3-caching-and-signaling
Use Signaling Pattern for Expensive Operations
When multiple callbacks need the same expensive computation, use the signaling pattern: one callback computes and caches, others retrieve from cache triggered by a signal.
Incorrect (duplicate expensive operations):
from dash import Input, Output, callback
@callback(Output("chart1", "figure"), Input("filters", "value"))
def update_chart1(filters):
data = expensive_query(filters) # Runs expensive query
return create_chart1(data)
@callback(Output("chart2", "figure"), Input("filters", "value"))
def update_chart2(filters):
data = expensive_query(filters) # Same expensive query again!
return create_chart2(data)
@callback(Output("table", "data"), Input("filters", "value"))
def update_table(filters):
data = expensive_query(filters) # And again!
return data
# Query runs 3 times for the same filtersCorrect (signaling pattern):
from dash import dcc, Input, Output, State, callback
from flask_caching import Cache
cache = Cache(config={"CACHE_TYPE": "filesystem", "CACHE_DIR": ".cache"})
# Layout includes signal store
dcc.Store(id="data-signal")
# Step 1: Compute and cache, then signal completion
@callback(
Output("data-signal", "data"),
Input("filters", "value"),
)
def compute_data(filters):
cache_key = f"data_{hash(tuple(filters))}"
# Compute expensive operation once
data = expensive_query(filters)
cache.set(cache_key, data)
# Return signal with cache key
return {"cache_key": cache_key, "timestamp": time.time()}
# Step 2: Retrieve from cache when signaled
@callback(
Output("chart1", "figure"),
Input("data-signal", "data"),
)
def update_chart1(signal):
if not signal:
return {}
data = cache.get(signal["cache_key"])
return create_chart1(data)
@callback(
Output("chart2", "figure"),
Input("data-signal", "data"),
)
def update_chart2(signal):
if not signal:
return {}
data = cache.get(signal["cache_key"])
return create_chart2(data)
@callback(
Output("table", "data"),
Input("data-signal", "data"),
)
def update_table(signal):
if not signal:
return []
data = cache.get(signal["cache_key"])
return data
# Query runs once, all callbacks get cached resultSimpler version with memoize:
@cache.memoize(timeout=300)
def get_filtered_data(filters_tuple):
"""Memoized - returns cached result for same input."""
return expensive_query(list(filters_tuple))
@callback(Output("chart1", "figure"), Input("filters", "value"))
def update_chart1(filters):
data = get_filtered_data(tuple(filters)) # Cached after first call
return create_chart1(data)
@callback(Output("chart2", "figure"), Input("filters", "value"))
def update_chart2(filters):
data = get_filtered_data(tuple(filters)) # Returns cached
return create_chart2(data)
# Second callback reuses cached result from firstWhen to use signaling:
| Scenario | Pattern |
|---|---|
| Same data, multiple views | Signaling |
| Sequential dependency | Signaling |
| Independent computations | Separate caching |
| Real-time updates | Avoid heavy caching |
Reference: https://dash.plotly.com/sharing-data-between-callbacks#example-3-caching-and-signaling
Return User-Friendly Error Messages
Catch specific exceptions in callbacks. Log technical details for debugging, but return user-friendly messages to the UI.
Incorrect (expose technical errors):
from dash import Input, Output, callback
@callback(Output("result", "children"), Input("btn", "n_clicks"))
def process(n):
data = fetch_from_api() # May raise ConnectionError
return process_data(data) # May raise ValueError
# User sees: "ConnectionError: Connection refused" - confusing and unhelpfulCorrect (catch and handle gracefully):
import logging
from dash import Input, Output, callback, no_update
import dash_mantine_components as dmc
logger = logging.getLogger(__name__)
@callback(Output("result", "children"), Input("btn", "n_clicks"), prevent_initial_call=True)
def process(n):
try:
data = fetch_from_api()
result = process_data(data)
return dmc.Alert(f"Success: {result}", color="green")
except ConnectionError as e:
logger.error(f"API connection failed: {e}", exc_info=True)
return dmc.Alert(
"Unable to connect to the server. Please try again later.",
color="red",
title="Connection Error",
)
except ValueError as e:
logger.warning(f"Invalid data received: {e}")
return dmc.Alert(
"The data could not be processed. Please check your input.",
color="orange",
title="Invalid Data",
)
except Exception as e:
logger.exception(f"Unexpected error: {e}")
return dmc.Alert(
"An unexpected error occurred. Please try again or contact support.",
color="red",
title="Error",
)Pattern: Error output component:
# Layout
dmc.Stack([
dmc.Button("Load Data", id="load-btn"),
html.Div(id="error-container"), # For error messages
html.Div(id="data-container"), # For success content
])
@callback(
Output("data-container", "children"),
Output("error-container", "children"),
Input("load-btn", "n_clicks"),
prevent_initial_call=True,
)
def load_data(n):
try:
data = fetch_data()
return format_data(data), "" # Clear error on success
except Exception as e:
logger.exception("Data load failed")
return no_update, dmc.Alert("Failed to load data", color="red")Use specific exceptions:
# Define custom exceptions for clarity
class ValidationError(Exception):
pass
class DataNotFoundError(Exception):
pass
@callback(...)
def process(n, filters):
try:
validate_filters(filters) # Raises ValidationError
data = query_data(filters) # Raises DataNotFoundError
return format_results(data)
except ValidationError as e:
return dmc.Alert(str(e), color="orange", title="Invalid Input")
except DataNotFoundError:
return dmc.Alert(
"No results found for your search criteria.",
color="blue",
title="No Results",
)Logging setup:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# In callback
logger.info(f"Processing request: {filters}")
logger.error(f"Failed to process: {e}", exc_info=True) # Include stack traceReference: https://dash.plotly.com/advanced-callbacks
Validate Early, Fail Fast
Validate all form inputs before processing. Collect all errors at once and display them clearly to the user.
Incorrect (validate one at a time):
from dash import Input, Output, State, callback, no_update
import dash_mantine_components as dmc
@callback(
Output("result", "children"),
Input("submit", "n_clicks"),
State("email", "value"),
State("password", "value"),
prevent_initial_call=True,
)
def submit(n, email, password):
# User fixes one error, submits, sees next error
if not email:
return dmc.Alert("Email required", color="red")
if "@" not in email:
return dmc.Alert("Invalid email", color="red")
if not password:
return dmc.Alert("Password required", color="red")
if len(password) < 8:
return dmc.Alert("Password too short", color="red")
# Finally process...
# Frustrating UX: fix one error, see anotherCorrect (collect all errors):
from dash import Input, Output, State, callback, no_update
import dash_mantine_components as dmc
@callback(
Output("result", "children"),
Output("email", "error"),
Output("password", "error"),
Input("submit", "n_clicks"),
State("email", "value"),
State("password", "value"),
prevent_initial_call=True,
)
def submit(n, email, password):
errors = []
email_error = ""
password_error = ""
# Validate all fields
if not email:
email_error = "Email is required"
errors.append(email_error)
elif "@" not in email:
email_error = "Please enter a valid email"
errors.append(email_error)
if not password:
password_error = "Password is required"
errors.append(password_error)
elif len(password) < 8:
password_error = "Password must be at least 8 characters"
errors.append(password_error)
# If any errors, show all at once
if errors:
return (
dmc.Alert(
title="Please fix the following errors:",
children=dmc.List([dmc.ListItem(e) for e in errors]),
color="red",
),
email_error,
password_error,
)
# All valid - process form
result = process_registration(email, password)
return (
dmc.Alert("Registration successful!", color="green"),
"", # Clear email error
"", # Clear password error
)Inline validation with debounce:
# Real-time field validation
@callback(
Output("email", "error"),
Input("email", "value"),
)
def validate_email(email):
if not email:
return "" # Don't show error for empty (not yet filled)
if "@" not in email:
return "Please enter a valid email"
return ""Validation helper function:
def validate_form(fields):
"""Generic form validator.
Args:
fields: List of (value, rules) tuples
rules: List of (check_fn, error_msg) tuples
Returns:
dict with 'valid' bool and 'errors' dict
"""
errors = {}
for field_name, value, rules in fields:
for check, msg in rules:
if not check(value):
errors[field_name] = msg
break # First error per field
return {"valid": not errors, "errors": errors}
# Usage
result = validate_form([
("email", email, [
(bool, "Email is required"),
(lambda v: "@" in v, "Invalid email format"),
]),
("password", password, [
(bool, "Password is required"),
(lambda v: len(v or "") >= 8, "Password must be 8+ characters"),
]),
])Reference: https://www.dash-mantine-components.com/components/textinput#with-error
Use Clientside Callbacks for Frequent Updates
Clientside callbacks run in the browser, eliminating server round-trips. Use them for frequent UI updates like theme toggles, form state, and animations.
Incorrect (server callback for theme toggle):
from dash import Input, Output, callback
@callback(
Output("mantine-provider", "forceColorScheme"),
Input("theme-toggle", "n_clicks"),
)
def toggle_theme(n):
# Round-trip to server just to toggle a boolean
return "dark" if n and n % 2 else "light"
# Adds 50-200ms latency on each clickCorrect (clientside callback):
from dash import Input, Output, clientside_callback
clientside_callback(
"""
function(n_clicks) {
if (!n_clicks) return window.dash_clientside.no_update;
const current = document.documentElement.getAttribute('data-mantine-color-scheme');
return current === 'light' ? 'dark' : 'light';
}
""",
Output("mantine-provider", "forceColorScheme"),
Input("theme-toggle", "n_clicks"),
)
# Instant response, no server communicationWhen to use clientside callbacks:
| Use Case | Why Clientside |
|---|---|
| Theme/color scheme toggle | UI-only, frequent |
| Show/hide elements | No data processing |
| Form field validation | Instant feedback |
| Tab switching | No server data needed |
| Animation triggers | Real-time response |
| Counter/timer display | High frequency updates |
Clientside with multiple outputs:
clientside_callback(
"""
function(checked) {
return [
checked ? 'block' : 'none',
checked ? 'Settings visible' : 'Settings hidden'
];
}
""",
Output("settings-panel", "style"),
Output("status-text", "children"),
Input("show-settings", "checked"),
)Access callback context:
clientside_callback(
"""
function(n1, n2) {
const triggered = dash_clientside.callback_context.triggered_id;
if (triggered === 'btn-1') return 'Button 1 clicked';
if (triggered === 'btn-2') return 'Button 2 clicked';
return dash_clientside.no_update;
}
""",
Output("output", "children"),
Input("btn-1", "n_clicks"),
Input("btn-2", "n_clicks"),
)Prevent update in clientside:
clientside_callback(
"""
function(value) {
if (!value) {
return window.dash_clientside.no_update;
}
return value.toUpperCase();
}
""",
Output("output", "children"),
Input("input", "value"),
)Keep complex logic server-side:
- Database queries
- Authentication
- Heavy computation
- File I/O
- API calls
Reference: https://dash.plotly.com/clientside-callbacks
Memoize Expensive Functions
Use @lru_cache or @cache.memoize() to cache results of expensive functions. This avoids redundant computation when the same inputs are used multiple times.
Incorrect (no memoization):
from dash import Input, Output, callback
import pandas as pd
def process_data(category, year):
"""Expensive: loads file, filters, aggregates."""
df = pd.read_csv("large_file.csv") # 100MB file
filtered = df[(df["category"] == category) & (df["year"] == year)]
return filtered.groupby("month").sum()
@callback(Output("chart", "figure"), Input("category", "value"), Input("year", "value"))
def update(category, year):
# Same category+year combination recomputes everything
data = process_data(category, year)
return create_chart(data)Correct (with lru_cache):
from functools import lru_cache
from dash import Input, Output, callback
import pandas as pd
@lru_cache(maxsize=128)
def process_data(category, year):
"""Cached: returns stored result for same inputs."""
df = pd.read_csv("large_file.csv")
filtered = df[(df["category"] == category) & (df["year"] == year)]
return filtered.groupby("month").sum().to_dict() # Must be hashable
@callback(Output("chart", "figure"), Input("category", "value"), Input("year", "value"))
def update(category, year):
# Same inputs return cached result instantly
data = process_data(category, year)
return create_chart(pd.DataFrame(data))With Flask-Caching:
from flask_caching import Cache
cache = Cache(config={"CACHE_TYPE": "filesystem", "CACHE_DIR": ".cache"})
@cache.memoize(timeout=3600) # Cache for 1 hour
def process_data(category, year):
"""Works with non-hashable returns."""
df = pd.read_csv("large_file.csv")
filtered = df[(df["category"] == category) & (df["year"] == year)]
return filtered.groupby("month").sum() # DataFrame OKMemoization requirements:
| Requirement | lru_cache | cache.memoize |
|---|---|---|
| Hashable args | Yes | Yes (converted) |
| Hashable return | Yes | No |
| TTL support | No | Yes |
| Persistent | No | Yes (with Redis) |
| Multi-worker | No | Yes (with Redis) |
Convert unhashable args:
@lru_cache(maxsize=64)
def filter_data(filters_tuple): # Tuple is hashable
filters = dict(filters_tuple) # Convert back to dict
return process(filters)
# Call with converted args
result = filter_data(tuple(filters.items()))Clear cache when needed:
# lru_cache
process_data.cache_clear()
# Flask-Caching
cache.delete_memoized(process_data)
cache.delete_memoized(process_data, "category1", 2024) # Specific argsMonitor cache effectiveness:
# lru_cache stats
info = process_data.cache_info()
print(f"Hits: {info.hits}, Misses: {info.misses}, Size: {info.currsize}")Reference: https://docs.python.org/3/library/functools.html#functools.lru_cache
Use WebGL for Large Scatter Plots
Standard SVG rendering becomes slow with large datasets. Use WebGL-based rendering for scatter plots with 100k+ points.
Incorrect (SVG with large dataset):
import plotly.express as px
import pandas as pd
df = pd.DataFrame({
"x": range(500000),
"y": [i * 0.5 for i in range(500000)],
})
# SVG rendering - browser freezes
fig = px.scatter(df, x="x", y="y")
# Takes 10+ seconds to render, browser may crashCorrect (WebGL rendering):
import plotly.express as px
import pandas as pd
df = pd.DataFrame({
"x": range(500000),
"y": [i * 0.5 for i in range(500000)],
})
# WebGL rendering - smooth performance
fig = px.scatter(df, x="x", y="y", render_mode="webgl")
# Renders in under 1 secondWith Plotly Graph Objects:
import plotly.graph_objects as go
fig = go.Figure(data=go.Scattergl( # Note: Scattergl, not Scatter
x=df["x"],
y=df["y"],
mode="markers",
marker=dict(size=3, color=df["color"]),
))Performance comparison:
| Data Points | SVG | WebGL |
|---|---|---|
| 10,000 | Fast | Fast |
| 50,000 | Slow | Fast |
| 100,000 | Very slow | Fast |
| 500,000 | Crashes | Fast |
| 1,000,000+ | Impossible | Acceptable |
WebGL limitations:
- No gradient fills
- Limited text rendering
- Simpler marker shapes
- May vary by GPU/browser
Line charts with large data:
import plotly.graph_objects as go
fig = go.Figure(data=go.Scattergl(
x=df["time"],
y=df["value"],
mode="lines", # Line mode in Scattergl
))Combine with data aggregation:
def downsample(df, target_points=10000):
"""Reduce points while preserving shape."""
if len(df) <= target_points:
return df
step = len(df) // target_points
return df.iloc[::step]
# For overview: downsample
# For zoom: load full resolution for visible rangeDMC Charts with WebGL:
import dash_mantine_components as dmc
# DMC charts use Recharts (SVG)
# For large datasets, use dcc.Graph with Plotly WebGL instead
from dash import dcc
dcc.Graph(
figure=px.scatter(df, x="x", y="y", render_mode="webgl")
)Reference: https://plotly.com/python/webgl-vs-svg/
Use classNames Over styles Prop
The classNames prop is preferred over styles for component customization. It provides better maintainability, CSS specificity control, and enables hover/focus states.
Incorrect (inline styles prop):
import dash_mantine_components as dmc
dmc.Button(
"Submit",
styles={
"root": {
"backgroundColor": "var(--mantine-color-green-6)",
"minWidth": "200px",
},
"label": {
"fontWeight": 700,
"textTransform": "uppercase",
},
},
)
# Inline styles can't handle :hover, :focus, media queries
# Harder to maintain, duplicated across componentsCorrect (classNames with CSS):
import dash_mantine_components as dmc
dmc.Button(
"Submit",
classNames={
"root": "submit-button",
"label": "submit-button-label",
},
)/* assets/styles.css */
.submit-button {
background-color: var(--mantine-color-green-6);
min-width: 200px;
}
.submit-button:hover {
background-color: var(--mantine-color-green-7);
}
.submit-button:active {
transform: translateY(1px);
}
.submit-button-label {
font-weight: 700;
text-transform: uppercase;
}Component element names:
# Button elements
classNames={
"root": "...", # Outer wrapper
"inner": "...", # Inner container
"label": "...", # Text content
"section": "...", # Left/right sections
"loader": "...", # Loading indicator
}
# TextInput elements
classNames={
"root": "...", # Outer wrapper
"wrapper": "...", # Input wrapper
"input": "...", # Actual input element
"label": "...", # Label text
"description": "...", # Description text
"error": "...", # Error message
}When styles prop is acceptable:
# Dynamic styles based on state
dmc.Box(
style={"backgroundColor": selected_color}, # Dynamic value
)
# One-off positioning
dmc.Tooltip(
styles={"tooltip": {"maxWidth": 300}}, # Unique to this instance
)Global component styling via theme:
dmc.MantineProvider(
theme={
"components": {
"Button": {
"classNames": {
"root": "app-button",
"label": "app-button-label",
},
},
},
},
)
# All Buttons get these classes automaticallyBenefits of classNames:
| Feature | classNames | styles |
|---|---|---|
| Hover/focus states | Yes | No |
| Media queries | Yes | No |
| CSS animations | Yes | Limited |
| Browser caching | Yes | No |
| Dev tools inspection | Easy | Harder |
| Code reuse | Yes | No |
Reference: https://mantine.dev/styles/styles-api/
Prefer CSS Variables Over Hardcoded Values
Use Mantine's CSS variables (--mantine-*) instead of hardcoded values. This ensures consistency with the theme and enables automatic light/dark mode support.
Incorrect (hardcoded values):
/* assets/styles.css */
.my-card {
background-color: #f8f9fa; /* Hardcoded gray */
border: 1px solid #dee2e6; /* Hardcoded border */
border-radius: 8px; /* Hardcoded radius */
padding: 16px; /* Hardcoded spacing */
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.my-button {
background-color: #228be6; /* Hardcoded blue */
color: white;
}
/* Breaks in dark mode, inconsistent with theme changes */Correct (CSS variables):
/* assets/styles.css */
.my-card {
background-color: var(--mantine-color-gray-0);
border: 1px solid var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-md);
box-shadow: var(--mantine-shadow-sm);
}
.my-button {
background-color: var(--mantine-color-blue-6);
color: var(--mantine-color-white);
}
/* Adapts to theme, consistent with other components */Common Mantine CSS variables:
| Category | Variables |
|---|---|
| Colors | --mantine-color-{name}-{0-9} |
| Spacing | --mantine-spacing-{xs,sm,md,lg,xl} |
| Radius | --mantine-radius-{xs,sm,md,lg,xl} |
| Shadows | --mantine-shadow-{xs,sm,md,lg,xl} |
| Font sizes | --mantine-font-size-{xs,sm,md,lg,xl} |
| Line heights | --mantine-line-height-{xs,sm,md,lg,xl} |
Light/dark mode with CSS variables:
/* Automatic dark mode support */
.my-component {
/* Uses theme-appropriate colors automatically */
background-color: var(--mantine-color-body);
color: var(--mantine-color-text);
border-color: var(--mantine-color-default-border);
}
/* Manual dark mode override if needed */
[data-mantine-color-scheme="dark"] .my-component {
background-color: var(--mantine-color-dark-6);
}Primary color variables:
.accent-element {
/* Uses theme's primary color */
background-color: var(--mantine-primary-color-filled);
color: var(--mantine-primary-color-contrast);
}
.accent-hover:hover {
background-color: var(--mantine-primary-color-filled-hover);
}Spacing scale:
| Variable | Default Value |
|---|---|
--mantine-spacing-xs | 10px |
--mantine-spacing-sm | 12px |
--mantine-spacing-md | 16px |
--mantine-spacing-lg | 20px |
--mantine-spacing-xl | 32px |
Reference: https://mantine.dev/styles/css-variables/
Limit Style Props to 3-4 Per Component
Mantine style props (m, p, w, h, c, bg, etc.) are convenient for quick styling, but excessive use reduces code readability. Use CSS files for complex styling.
Incorrect (too many style props):
import dash_mantine_components as dmc
dmc.Card(
children=[...],
p="xl",
m="md",
w=400,
h=300,
bg="gray.1",
c="dark.9",
radius="lg",
shadow="md",
withBorder=True,
style={"borderColor": "var(--mantine-color-blue-5)"},
# Hard to read, hard to maintain
)Correct (limit style props, use CSS for complex styling):
import dash_mantine_components as dmc
# Component with limited style props
dmc.Card(
children=[...],
p="xl",
radius="lg",
className="feature-card", # Complex styles in CSS
)/* assets/styles.css */
.feature-card {
width: 400px;
height: 300px;
background-color: var(--mantine-color-gray-1);
color: var(--mantine-color-dark-9);
box-shadow: var(--mantine-shadow-md);
border: 1px solid var(--mantine-color-blue-5);
}Acceptable style prop usage (3-4 props):
# Spacing and sizing - OK
dmc.Container(children=[...], p="md", size="lg")
# Quick layout adjustments - OK
dmc.Group(children=[...], gap="sm", justify="space-between")
# Common patterns - OK
dmc.Button("Submit", size="lg", radius="md", fullWidth=True)When to use CSS instead:
- More than 4 style props on one component
- Repeated styling patterns across components
- Complex hover/focus states
- Responsive breakpoints
- Animations and transitions
Style props reference:
| Prop | CSS Property | Example |
|---|---|---|
m, mt, mb, ml, mr, mx, my | margin | m="md" |
p, pt, pb, pl, pr, px, py | padding | p="xl" |
w | width | w={300} or w="100%" |
h | height | h={200} |
c | color | c="blue.6" |
bg | background | bg="gray.1" |
fz | font-size | fz="lg" |
fw | font-weight | fw={700} |
Reference: https://mantine.dev/styles/style-props/
Use CSS Media Queries for Responsive Design
CSS media queries are more performant than responsive style props. Responsive props generate CSS for every breakpoint which increases bundle size.
Incorrect (responsive style props for complex layouts):
import dash_mantine_components as dmc
dmc.SimpleGrid(
children=[...],
cols={"base": 1, "sm": 2, "md": 3, "lg": 4}, # Generates CSS for each
spacing={"base": "sm", "md": "lg"},
)
dmc.Container(
children=[...],
p={"base": "xs", "sm": "sm", "md": "md", "lg": "xl"}, # More generated CSS
size={"base": "100%", "md": "lg"},
)
# Adds significant CSS to bundle for each responsive propCorrect (CSS media queries):
import dash_mantine_components as dmc
dmc.SimpleGrid(
children=[...],
className="responsive-grid",
)/* assets/styles.css */
.responsive-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--mantine-spacing-sm);
}
@media (min-width: 48em) { /* sm: 768px */
.responsive-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 62em) { /* md: 992px */
.responsive-grid {
grid-template-columns: repeat(3, 1fr);
gap: var(--mantine-spacing-lg);
}
}
@media (min-width: 75em) { /* lg: 1200px */
.responsive-grid {
grid-template-columns: repeat(4, 1fr);
}
}When responsive props are OK:
# Simple cases with 1-2 breakpoints - acceptable
dmc.Stack(gap={"base": "sm", "md": "lg"})
# Built-in responsive components - designed for it
dmc.Container(size="lg") # Responsive by defaultMantine breakpoints:
| Name | em | px |
|---|---|---|
| xs | 36em | 576px |
| sm | 48em | 768px |
| md | 62em | 992px |
| lg | 75em | 1200px |
| xl | 88em | 1408px |
Use em units in media queries:
/* Prefer em for better accessibility (respects user font size) */
@media (min-width: 48em) { ... }
/* Avoid px */
@media (min-width: 768px) { ... }Visibility helpers:
# Use hiddenFrom/visibleFrom for show/hide
dmc.Text("Mobile only", hiddenFrom="md")
dmc.Text("Desktop only", visibleFrom="md")Reference: https://mantine.dev/styles/responsive/
Use Static Selectors Only
Mantine generates dynamic class names like .m_77c9d27d that change between versions. Always use static selectors (.mantine-*) or data attributes instead.
Incorrect (targeting dynamic classes):
/* assets/styles.css */
/* BREAKS on any DMC update - hash changes */
.m_77c9d27d {
background-color: red;
}
.m_8d3f4cd2 .m_1a2b3c4d {
padding: 10px;
}Correct (using static Mantine selectors):
/* assets/styles.css */
/* Static selectors - stable across versions */
.mantine-Button-root {
background-color: red;
}
.mantine-Card-root .mantine-Text-root {
padding: 10px;
}
/* Component-specific selectors */
.mantine-TextInput-input {
border-radius: 8px;
}
.mantine-Select-dropdown {
max-height: 300px;
}Correct (using data attributes):
/* Target component states via data attributes */
.mantine-Button-root[data-disabled="true"] {
opacity: 0.5;
}
.mantine-Button-root[data-loading="true"] {
cursor: wait;
}
.mantine-Checkbox-input[data-indeterminate="true"] {
background-color: var(--mantine-color-blue-light);
}Correct (using classNames prop):
import dash_mantine_components as dmc
# Add custom class via classNames prop
dmc.Button(
"Submit",
classNames={"root": "my-submit-btn", "label": "my-btn-label"}
)/* Target your custom class */
.my-submit-btn {
min-width: 200px;
}
.my-btn-label {
font-weight: bold;
}Finding static selectors:
1. Inspect element in browser dev tools 2. Look for classes starting with mantine- 3. Pattern: .mantine-{ComponentName}-{element}
Common static selectors:
| Component | Selectors |
|---|---|
| Button | .mantine-Button-root, .mantine-Button-label, .mantine-Button-inner |
| TextInput | .mantine-TextInput-root, .mantine-TextInput-input, .mantine-TextInput-label |
| Select | .mantine-Select-root, .mantine-Select-input, .mantine-Select-dropdown |
| Card | .mantine-Card-root, .mantine-Card-section |
| Modal | .mantine-Modal-root, .mantine-Modal-header, .mantine-Modal-body |
Reference: https://mantine.dev/styles/styles-api/
Set Component Defaults in Theme
Configure default props for components in the theme instead of repeating them on every instance. This ensures consistency and simplifies maintenance.
Incorrect (repeating props everywhere):
import dash_mantine_components as dmc
# Same props repeated on every button
dmc.Button("Save", size="md", radius="md", variant="filled")
dmc.Button("Cancel", size="md", radius="md", variant="outline")
dmc.Button("Delete", size="md", radius="md", variant="filled", color="red")
# Same props on every input
dmc.TextInput(label="Name", size="md", radius="md")
dmc.TextInput(label="Email", size="md", radius="md")
dmc.TextInput(label="Phone", size="md", radius="md")
# Tedious, error-prone, hard to change globallyCorrect (centralized theme defaults):
import dash_mantine_components as dmc
theme = {
"components": {
"Button": {
"defaultProps": {
"size": "md",
"radius": "md",
},
},
"TextInput": {
"defaultProps": {
"size": "md",
"radius": "md",
},
},
"Select": {
"defaultProps": {
"size": "md",
"radius": "md",
"searchable": True,
},
},
},
}
dmc.MantineProvider(theme=theme, children=[
# Now just use components without repeating defaults
dmc.Button("Save"), # Gets size="md", radius="md" automatically
dmc.Button("Cancel", variant="outline"), # Override only what differs
dmc.TextInput(label="Name"), # Gets defaults automatically
])Setting default styles:
theme = {
"components": {
"Button": {
"defaultProps": {
"size": "md",
},
"styles": {
"root": {
"fontWeight": 600,
},
"label": {
"textTransform": "uppercase",
},
},
},
},
}Setting default classNames:
theme = {
"components": {
"Card": {
"classNames": {
"root": "app-card",
"section": "app-card-section",
},
},
},
}/* assets/styles.css */
.app-card {
transition: transform 0.2s;
}
.app-card:hover {
transform: translateY(-2px);
}Common defaults to set:
theme = {
"components": {
# Form inputs
"TextInput": {"defaultProps": {"size": "md", "radius": "sm"}},
"Select": {"defaultProps": {"size": "md", "searchable": True}},
"Textarea": {"defaultProps": {"size": "md", "autosize": True}},
# Buttons
"Button": {"defaultProps": {"size": "md", "radius": "md"}},
"ActionIcon": {"defaultProps": {"variant": "subtle"}},
# Layout
"Card": {"defaultProps": {"radius": "md", "withBorder": True}},
"Modal": {"defaultProps": {"centered": True, "radius": "md"}},
# Feedback
"Notification": {"defaultProps": {"radius": "md"}},
"Alert": {"defaultProps": {"radius": "md"}},
},
}Override defaults when needed:
# Theme sets Button radius="md" by default
# Override for specific instance
dmc.Button("Pill Button", radius="xl") # This instance uses xlReference: https://mantine.dev/theming/default-props/
Custom Colors Need 10 Shades
Custom colors in Mantine must have exactly 10 shades (indices 0-9). Incomplete palettes cause components to fail when accessing missing shades.
Incorrect (incomplete palette):
import dash_mantine_components as dmc
dmc.MantineProvider(
theme={
"colors": {
"brand": ["#E3F2FD", "#2196F3", "#0D47A1"], # Only 3 colors
},
"primaryColor": "brand",
},
children=[...]
)
# Error: Cannot read properties of undefined (reading '6')
# Component tries to access brand.6 which doesn't existCorrect (full 10-shade palette):
import dash_mantine_components as dmc
dmc.MantineProvider(
theme={
"colors": {
"brand": [
"#E3F2FD", # 0 - lightest
"#BBDEFB", # 1
"#90CAF9", # 2
"#64B5F6", # 3
"#42A5F5", # 4
"#2196F3", # 5
"#1E88E5", # 6 - default for buttons
"#1976D2", # 7
"#1565C0", # 8
"#0D47A1", # 9 - darkest
],
},
"primaryColor": "brand",
},
children=[...]
)Use Mantine Color Generator:
Visit https://mantine.dev/colors-generator/ to generate a complete 10-shade palette from a single color.
Shade usage:
| Shade | Light Mode Usage | Dark Mode Usage |
|---|---|---|
| 0-2 | Backgrounds, hover | Text on dark bg |
| 3-4 | Borders, dividers | Secondary elements |
| 5-6 | Primary actions | Primary actions |
| 7-8 | Hover states | Backgrounds |
| 9 | Text, emphasis | Lightest backgrounds |
Set different primary shades for light/dark:
theme = {
"colors": {"brand": [...]}, # 10 shades
"primaryColor": "brand",
"primaryShade": {"light": 6, "dark": 8}, # Different shades per scheme
}Using custom color in components:
# Reference by name and shade
dmc.Button("Click", color="brand") # Uses primaryShade
dmc.Button("Click", color="brand.7") # Explicit shade
dmc.Text("Hello", c="brand.9") # Text color
dmc.Box(bg="brand.0") # BackgroundExtending built-in colors:
theme = {
"colors": {
# Override specific shades of built-in color
"blue": [
"#E7F5FF",
"#D0EBFF",
"#A5D8FF",
"#74C0FC",
"#4DABF7",
"#339AF0",
"#228BE6",
"#1C7ED6",
"#1971C2",
"#1864AB",
],
},
}Reference: https://mantine.dev/theming/colors/
Test Both Light and Dark Modes
Always test your application in both light and dark modes. Colors, contrasts, and custom styles may look correct in one mode but broken in the other.
Incorrect (only tested in light mode):
import dash_mantine_components as dmc
dmc.Card(
style={"backgroundColor": "#ffffff"}, # Hardcoded white
children=[
dmc.Text("Content", style={"color": "#333333"}), # Hardcoded dark
]
)
# In dark mode: white card on dark background looks jarring
# Text might be invisible on some backgroundsCorrect (theme-aware colors):
import dash_mantine_components as dmc
dmc.Card(
children=[
dmc.Text("Content"), # Uses theme text color automatically
]
)
# Automatically adapts to light/dark modeSetting color scheme:
# Default to light, allow switching
dmc.MantineProvider(
id="mantine-provider",
defaultColorScheme="light", # Default only
children=[...]
)
# Force specific scheme (no switching)
dmc.MantineProvider(
forceColorScheme="dark", # Always dark
children=[...]
)Theme toggle implementation:
from dash import clientside_callback, Input, Output
import dash_mantine_components as dmc
# Toggle button
dmc.ActionIcon(
dmc.DashIconify(icon="tabler:sun"),
id="theme-toggle",
variant="default",
)
# Clientside callback for instant toggle
clientside_callback(
"""
function(n) {
if (!n) return window.dash_clientside.no_update;
const html = document.documentElement;
const current = html.getAttribute('data-mantine-color-scheme') || 'light';
return current === 'light' ? 'dark' : 'light';
}
""",
Output("mantine-provider", "forceColorScheme"),
Input("theme-toggle", "n_clicks"),
)CSS for color scheme:
/* Target specific color scheme */
[data-mantine-color-scheme="dark"] .my-component {
border-color: var(--mantine-color-dark-4);
}
[data-mantine-color-scheme="light"] .my-component {
border-color: var(--mantine-color-gray-3);
}
/* Modern CSS light-dark() function */
.my-component {
background: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-7)
);
}Testing checklist:
- [ ] Text readable in both modes
- [ ] Sufficient contrast ratios
- [ ] Custom backgrounds adapt
- [ ] Borders visible in both modes
- [ ] Icons/images appropriate for both
- [ ] Charts/graphs readable
- [ ] Form inputs styled correctly
- [ ] Error states visible
Common issues:
| Issue | Light Mode | Dark Mode | Fix |
|---|---|---|---|
| Invisible text | OK | Text disappears | Use theme colors |
| Low contrast | OK | Hard to read | Check contrast ratios |
| Harsh colors | OK | Too bright | Use dimmed variants |
| Lost borders | OK | Border invisible | Use theme border color |
Reference: https://mantine.dev/theming/color-schemes/
DMC v2.x Breaking Changes
DMC v2.x (based on Mantine 8) includes several breaking changes from v1.x. Review these when upgrading.
DateTimePicker: timeInputProps → timePickerProps
# v1.x (DEPRECATED)
dmc.DateTimePicker(
timeInputProps={"leftSection": icon}
)
# v2.x (CORRECT)
dmc.DateTimePicker(
timePickerProps={
"leftSection": icon,
"minutesStep": 5,
"withDropdown": True,
}
)Carousel: emblaOptions wrapper
# v1.x (DEPRECATED)
dmc.Carousel(
loop=True,
dragFree=True,
align="start",
speed=10, # REMOVED in v2
draggable=True # REMOVED in v2
)
# v2.x (CORRECT)
dmc.Carousel(
emblaOptions={
"loop": True,
"dragFree": True,
"align": "start",
}
)Image: flex default changed
# v1.x: Image had flex: 0 by default
# v2.x: No flex default, add explicitly if needed
dmc.Image(src="logo.png", h=40, flex=0)DatesProvider: timezone removed
# v1.x (DEPRECATED)
dmc.DatesProvider(
settings={"timezone": "UTC", "consistentWeeks": True}
)
# v2.x (CORRECT)
dmc.DatesProvider(
settings={"consistentWeeks": True}
)
# Handle timezone in Python code insteadDefault behavior changes:
| Component | v1.x Default | v2.x Default | Revert |
|---|---|---|---|
| Popover.hideDetached | False | True | hideDetached=False |
| Portal.reuseTargetNode | False | True | reuseTargetNode=False |
| Switch.withThumbIndicator | False | True | withThumbIndicator=False |
Revert defaults via theme:
theme = {
"components": {
"Popover": {"defaultProps": {"hideDetached": False}},
"Portal": {"defaultProps": {"reuseTargetNode": False}},
"Switch": {"defaultProps": {"withThumbIndicator": False}},
}
}Menu: data-hovered removed
/* v1.x (DEPRECATED) */
.mantine-Menu-item[data-hovered] {
background-color: red;
}
/* v2.x (CORRECT) */
.mantine-Menu-item:hover,
.mantine-Menu-item:focus {
background-color: red;
}Migration checklist:
- [ ] Replace
timeInputPropswithtimePickerProps - [ ] Wrap Carousel options in
emblaOptions - [ ] Remove
speedanddraggablefrom Carousel - [ ] Add
flex=0to Images if needed - [ ] Remove
timezonefrom DatesProvider - [ ] Update CSS using
data-hovered - [ ] Test Popover/Portal z-index
- [ ] Check Switch appearance
- [ ] Replace NotificationProvider with NotificationContainer
Reference: https://mantine.dev/changelog/8-0-0/
Use NotificationContainer Not NotificationProvider
NotificationProvider is deprecated in DMC v2.x. Use NotificationContainer instead for the notification system.
Incorrect (deprecated NotificationProvider):
import dash_mantine_components as dmc
# v1.x style - DEPRECATED
app.layout = dmc.MantineProvider([
dmc.NotificationProvider(position="top-right"), # OLD API
dmc.Container([...]),
])
# Shows deprecation warning, will be removed in future versionsCorrect (NotificationContainer):
import dash_mantine_components as dmc
# v2.x style - CORRECT
app.layout = dmc.MantineProvider([
dmc.NotificationContainer(position="top-right"), # NEW API
dmc.Container([...]),
])NotificationContainer props:
dmc.NotificationContainer(
position="top-right", # Position on screen
autoClose=5000, # Auto-close after 5 seconds
limit=5, # Max visible notifications
zIndex=1000, # Stack order
containerWidth=440, # Width in pixels
notificationMaxHeight=200, # Max height per notification
)Position options:
| Position | Description |
|---|---|
top-right | Top right corner (default) |
top-left | Top left corner |
top-center | Top center |
bottom-right | Bottom right corner |
bottom-left | Bottom left corner |
bottom-center | Bottom center |
Showing notifications:
from dash import Input, Output, callback
import dash_mantine_components as dmc
@callback(
Output("notifications-container", "children"),
Input("show-notification-btn", "n_clicks"),
prevent_initial_call=True,
)
def show_notification(n):
return dmc.Notification(
title="Success!",
message="Your action was completed.",
color="green",
icon=DashIconify(icon="tabler:check"),
action="show",
autoClose=5000,
)Notification with action buttons:
dmc.Notification(
title="Confirm Action",
message="Are you sure you want to proceed?",
color="blue",
action="show",
autoClose=False, # Don't auto-close
)Programmatic notification control:
# Show notification
dmc.Notification(id="my-notif", action="show", ...)
# Hide specific notification
dmc.Notification(id="my-notif", action="hide")
# Update existing notification
dmc.Notification(id="my-notif", action="update", message="Updated message")Multiple notification containers (rare):
# If you need different positions for different notification types
dmc.NotificationContainer(id="success-notifs", position="top-right")
dmc.NotificationContainer(id="error-notifs", position="bottom-right")Reference: https://www.dash-mantine-components.com/components/notification