Why Organization Matters When Your Tkinter App Grows
A small Tkinter script can keep everything in one file and one window. As soon as you add multiple screens, shared state, and non-trivial actions (loading data, calculations, persistence), the code can become hard to change without breaking something. Maintainable Tkinter apps typically use three ideas:
- Frames as views: each “page” or section of the UI is a
tk.Frame(orttk.Frame) that can be shown/hidden. - Reusable components: custom Frame classes that encapsulate a piece of UI (and its internal widgets) behind a small API.
- MVC-like separation: keep business logic and state in model/service classes, keep UI in view classes, and use a controller (or coordinator) to connect them.
This chapter shows a practical structure you can apply immediately: a multi-page app that switches between frames, shares state safely, and uses a small controller layer to coordinate actions.
A Maintainable Folder Structure (Views, Models, Utilities)
One common structure is to split code by responsibility. You can start with this layout and expand later:
my_app/ app.py controllers/ app_controller.py models/ app_state.py services.py views/ main_window.py pages/ home_page.py settings_page.py components/ labeled_entry.py nav_bar.py utils/ formatting.pyGuidelines:
- views/ contains Tkinter widgets (Frames, windows, reusable components).
- models/ contains state and business rules (no Tkinter imports if possible).
- controllers/ contains glue code that responds to UI events and calls model/services.
- utils/ contains small pure functions (formatting, parsing, helpers).
Frames as “Pages”: A Multi-View App Pattern
A practical way to build multi-view apps is to create a single root window and a “container” Frame that holds multiple page Frames. Only one page is visible at a time. This avoids creating/destroying windows and makes navigation predictable.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Step-by-step: Create a Page Container and Switch Pages
The pattern below uses a show_page(name) method that raises the desired frame to the top.
import tkinter as tkfrom tkinter import ttkclass MainWindow(ttk.Frame): def __init__(self, master, controller): super().__init__(master) self.controller = controller self.pack(fill="both", expand=True) self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) self.nav = NavBar(self, on_navigate=self.controller.navigate) self.nav.grid(row=0, column=0, sticky="ew") self.container = ttk.Frame(self) self.container.grid(row=1, column=0, sticky="nsew") self.container.rowconfigure(0, weight=1) self.container.columnconfigure(0, weight=1) self.pages = {} def add_page(self, name, page_cls): page = page_cls(self.container, controller=self.controller) page.grid(row=0, column=0, sticky="nsew") self.pages[name] = page def show_page(self, name): self.pages[name].tkraise()class NavBar(ttk.Frame): def __init__(self, master, on_navigate): super().__init__(master) ttk.Button(self, text="Home", command=lambda: on_navigate("home")).pack(side="left") ttk.Button(self, text="Settings", command=lambda: on_navigate("settings")).pack(side="left")Key idea: MainWindow is a view that owns the container and page instances, but it does not decide navigation rules. It delegates navigation to the controller.
Reusable Components: Custom Frame Widgets With a Small API
Reusable components are custom Frame classes that encapsulate a set of widgets and expose methods like get(), set(), clear(), or set_error(). This keeps page code short and consistent.
Example: A Labeled Entry Component
import tkinter as tkfrom tkinter import ttkclass LabeledEntry(ttk.Frame): def __init__(self, master, label, textvariable=None, width=24): super().__init__(master) self.var = textvariable or tk.StringVar() self.label = ttk.Label(self, text=label) self.entry = ttk.Entry(self, textvariable=self.var, width=width) self.error = ttk.Label(self, foreground="red") self.label.grid(row=0, column=0, sticky="w") self.entry.grid(row=1, column=0, sticky="ew") self.error.grid(row=2, column=0, sticky="w") self.columnconfigure(0, weight=1) def get(self): return self.var.get() def set(self, value): self.var.set(value) def set_error(self, message): self.error.config(text=message or "")Benefits:
- Pages don’t need to know how the component is built internally.
- You can standardize spacing, styling, and error display in one place.
- Refactoring becomes safer: you change the component once, not every page.
MVC-like Separation: Views, Models, and a Small Controller
Tkinter doesn’t force an architecture, but an MVC-like approach works well:
- Model: application state and rules (e.g., user settings, current document, computed results).
- View: Frames and widgets; they render state and collect user input.
- Controller: coordinates actions; reads from views, updates models, triggers view updates and navigation.
In practice, you can keep views “dumb”: they call controller methods and expose small methods for the controller to update UI.
Model: Central App State (Shared Safely)
Shared state should live in one place, not scattered across global variables and widget variables. A simple model can be a dataclass. Keep it independent of Tkinter so it’s testable.
from dataclasses import dataclass@dataclassclass AppState: username: str = "" theme: str = "light"Service Layer: Business Logic Without Tkinter
For non-trivial logic, use services. They can validate, compute, load/save, or transform data. Keep them pure when possible.
class SettingsService: VALID_THEMES = {"light", "dark"} def apply_settings(self, state, username, theme): username = (username or "").strip() if not username: raise ValueError("Username is required") if theme not in self.VALID_THEMES: raise ValueError("Invalid theme") state.username = username state.theme = themeController: One Place to Coordinate Navigation and Actions
class AppController: def __init__(self, state, service): self.state = state self.service = service self.main_window = None def attach_main_window(self, main_window): self.main_window = main_window def navigate(self, page_name): self.main_window.show_page(page_name) self.refresh_page(page_name) def refresh_page(self, page_name): page = self.main_window.pages[page_name] if hasattr(page, "load_from_state"): page.load_from_state(self.state) def save_settings(self, username, theme): self.service.apply_settings(self.state, username, theme) self.navigate("home")Notice the direction of dependencies:
- Controller knows about state and services.
- Views know about controller (to call methods) but not about services.
- Models/services do not import Tkinter.
Building the Pages (Views) to Work With the Controller
Home Page: Display State
import tkinter as tkfrom tkinter import ttkclass HomePage(ttk.Frame): def __init__(self, master, controller): super().__init__(master) self.controller = controller self.title = ttk.Label(self, text="Home", font=("TkDefaultFont", 14, "bold")) self.info = ttk.Label(self) self.title.pack(anchor="w", padx=12, pady=(12, 6)) self.info.pack(anchor="w", padx=12) def load_from_state(self, state): self.info.config(text=f"User: {state.username or '(not set)'} | Theme: {state.theme}")Settings Page: Edit State Using Reusable Components
import tkinter as tkfrom tkinter import ttkclass SettingsPage(ttk.Frame): def __init__(self, master, controller): super().__init__(master) self.controller = controller self.username = LabeledEntry(self, "Username") self.theme = ttk.Combobox(self, values=["light", "dark"], state="readonly", width=22) self.theme_error = ttk.Label(self, foreground="red") self.save_btn = ttk.Button(self, text="Save", command=self.on_save) ttk.Label(self, text="Settings", font=("TkDefaultFont", 14, "bold")).grid(row=0, column=0, sticky="w", padx=12, pady=(12, 6)) self.username.grid(row=1, column=0, sticky="ew", padx=12, pady=6) ttk.Label(self, text="Theme").grid(row=2, column=0, sticky="w", padx=12, pady=(6, 0)) self.theme.grid(row=3, column=0, sticky="w", padx=12, pady=(0, 0)) self.theme_error.grid(row=4, column=0, sticky="w", padx=12, pady=(2, 6)) self.save_btn.grid(row=5, column=0, sticky="w", padx=12, pady=6) self.columnconfigure(0, weight=1) def load_from_state(self, state): self.username.set(state.username) self.theme.set(state.theme) self.username.set_error("") self.theme_error.config(text="") def on_save(self): self.username.set_error("") self.theme_error.config(text="") try: self.controller.save_settings(self.username.get(), self.theme.get()) except ValueError as e: msg = str(e) if "Username" in msg: self.username.set_error(msg) else: self.theme_error.config(text=msg)The Settings page does not directly modify the model. It delegates to the controller, which uses the service to enforce rules and update state.
Putting It Together: App Bootstrap (Composition Root)
Keep object creation in one place (often app.py). This makes dependencies explicit and simplifies testing.
import tkinter as tkfrom tkinter import ttkdef main(): root = tk.Tk() root.title("Maintainable Tkinter App") state = AppState() service = SettingsService() controller = AppController(state, service) main_window = MainWindow(root, controller=controller) controller.attach_main_window(main_window) main_window.add_page("home", HomePage) main_window.add_page("settings", SettingsPage) controller.navigate("home") root.mainloop()if __name__ == "__main__": main()Sharing State Safely Between Frames
When multiple pages need the same data, avoid:
- Global variables
- Direct page-to-page calls like
settings_page.home_page.update(...) - Storing “truth” only in widget variables
Prefer:
- A single
AppStateinstance owned by the controller - Pages that implement
load_from_state(state)(render state) and call controller methods to request changes - Controller-driven refresh after state changes (or a simple observer pattern if needed)
If you need more reactive updates later, you can add a tiny subscription mechanism to the model (listeners) or keep it in the controller (publish events). Start simple: refresh the target page when navigating or after saving.
Refactoring Lab: Extract a Complex Window Into Components + Add a Controller
This lab simulates a common situation: you have one large window with many widgets and callbacks. You will refactor it into reusable components and introduce a controller to coordinate actions.
Starting Point: A Single “God Frame” With Mixed Concerns
Imagine a single file where UI creation, state, and logic are intertwined:
import tkinter as tkfrom tkinter import ttkclass BigWindow(ttk.Frame): def __init__(self, master): super().__init__(master) self.pack(fill="both", expand=True) self.username_var = tk.StringVar() self.theme_var = tk.StringVar(value="light") ttk.Label(self, text="Username").grid(row=0, column=0, sticky="w") ttk.Entry(self, textvariable=self.username_var).grid(row=1, column=0, sticky="ew") ttk.Label(self, text="Theme").grid(row=2, column=0, sticky="w") ttk.Combobox(self, textvariable=self.theme_var, values=["light", "dark"], state="readonly").grid(row=3, column=0, sticky="w") self.msg = ttk.Label(self, foreground="red") self.msg.grid(row=4, column=0, sticky="w") ttk.Button(self, text="Save", command=self.save).grid(row=5, column=0, sticky="w") self.columnconfigure(0, weight=1) def save(self): u = self.username_var.get().strip() t = self.theme_var.get() if not u: self.msg.config(text="Username is required") return if t not in ("light", "dark"): self.msg.config(text="Invalid theme") return self.msg.config(text="Saved!")Problems:
- Validation rules are embedded in the view.
- No shared state object; data lives in widget variables.
- Hard to reuse the username field UI elsewhere.
- Hard to test validation without Tkinter.
Step 1: Extract Reusable Components
Replace the raw Entry + label + message with LabeledEntry. This immediately reduces clutter and standardizes error display.
# in views/components/labeled_entry.py# (use the LabeledEntry class shown earlier)Step 2: Introduce a Model for State
Create AppState and decide what is “truth” in the app (username, theme). Widget variables become just a UI representation.
# in models/app_state.pyfrom dataclasses import dataclass@dataclassclass AppState: username: str = "" theme: str = "light"Step 3: Move Rules Into a Service
Extract validation and application of settings into SettingsService. The view should not decide what is valid.
# in models/services.pyclass SettingsService: VALID_THEMES = {"light", "dark"} def apply_settings(self, state, username, theme): username = (username or "").strip() if not username: raise ValueError("Username is required") if theme not in self.VALID_THEMES: raise ValueError("Invalid theme") state.username = username state.theme = themeStep 4: Add a Controller to Coordinate UI and Model
The controller becomes the single place that handles “Save clicked” and decides what happens next (show message, navigate, refresh).
# in controllers/app_controller.pyclass AppController: def __init__(self, state, settings_service): self.state = state self.settings_service = settings_service self.main_window = None def attach_main_window(self, main_window): self.main_window = main_window def save_settings(self, username, theme): self.settings_service.apply_settings(self.state, username, theme)Step 5: Rebuild the Window as Pages (Optional but Recommended)
Even if you only have one screen today, structuring as pages makes growth easier. Create HomePage and SettingsPage as shown earlier, and let MainWindow switch between them.
Checklist for the refactor:
- UI widgets live in view classes under
views/. - State lives in
AppStateand is passed to views only for rendering. - Rules live in services under
models/. - Views call controller methods; controller updates state and triggers navigation/refresh.
- Reusable UI chunks are custom Frame components under
views/components/.
Practical Tips for Keeping Tkinter Code Maintainable
- Keep constructors lightweight: build widgets and layout, but avoid heavy work (file I/O, network). Trigger those via controller actions.
- Expose small view APIs: methods like
load_from_state,set_status,set_errorare easier than reaching into widget attributes from outside. - Prefer dependency injection: pass controller (and only controller) into views; keep services behind the controller.
- Use consistent naming:
HomePage,SettingsPage,NavBar,LabeledEntry,AppState,SettingsService. - Centralize navigation: one
navigate()method in the controller reduces coupling between pages.