Free Ebook cover Desktop Apps with Tkinter: A Beginner’s Guide to Python GUIs

Desktop Apps with Tkinter: A Beginner’s Guide to Python GUIs

New course

10 pages

Organizing a Maintainable Tkinter App: Frames, Reusable Components, and MVC-like Patterns

Capítulo 9

Estimated reading time: 11 minutes

+ Exercise

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 (or ttk.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.py

Guidelines:

  • 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 App

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 = theme

Controller: 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 AppState instance 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 = theme

Step 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 AppState and 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_error are 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.

Now answer the exercise about the content:

In a maintainable multi-page Tkinter app using an MVC-like approach, which design best reduces coupling between pages while sharing state safely?

You are right! Congratulations, now go to the next page

You missed! Try again.

A controller-owned AppState centralizes shared data, services keep rules out of the UI, and pages stay simple by rendering state via load_from_state and delegating actions to the controller.

Next chapter

Packaging and Distributing a Tkinter Desktop App

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.