This document provides an in-depth explanation of the architecture used in the Task Manager sample application, demonstrating best practices for building Fabulous MAUI applications.
The MVU architecture is a functional pattern for building UIs with the following characteristics:
- Unidirectional Data Flow: Data flows in one direction: Model → View → Message → Update → Model
- Immutability: The model is never mutated; updates create new model instances
- Pure Functions: View and Update functions are pure (no side effects)
- Predictability: Same input always produces the same output
┌─────────────────────────────────────────────┐
│ View Layer │
│ (Renders UI based on current model) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ User Interactions │
│ (Button clicks, text input, etc.) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Messages (Msg) │
│ (Events that describe what happened) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Update Function │
│ (Pure function: Msg → Model → Model) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Command Messages │
│ (Async operations, side effects) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Updated Model │
│ (New immutable state) │
└─────────────────────────────────────────────┘
↓
(Cycle repeats)
Each feature is self-contained with its own Types, State, and View:
Features/
├── TaskList/
│ ├── Types.fs # Messages, Models, Navigation events
│ ├── State.fs # State management and business logic
│ └── View.fs # UI rendering
└── TaskDetail/
├── Types.fs
├── State.fs
└── View.fs
type Msg =
| UserAction // Direct user interactions
| DataLoaded of Data // Async operation results
| ErrorOccurred of string
type CmdMsg =
| LoadData // Commands for side effects
| SaveData of Data
type NavigationMsg =
| NavigateToDetail // Navigation events
| NavigateBackPurpose: Defines the contract for all possible events and commands in the feature.
let init () =
{ /* initial model */ }, [ /* initial commands */ ]
let update msg model =
match msg with
| UserAction ->
{ model with /* changes */ }, [ /* commands */ ], None
| DataLoaded data ->
{ model with Data = data }, [], Some NavigationEvent
let mapCmdMsg cmdMsg =
match cmdMsg with
| LoadData ->
Cmd.ofAsyncMsg (async {
let! data = Api.loadData()
return DataLoaded data
})Purpose: Contains all state transitions and business logic.
let view model =
ContentPage(
"Title",
VStack() {
Label(model.Text)
Button("Action", UserAction)
}
)Purpose: Pure function that renders UI based on the model.
The Root module manages navigation through a page stack:
type Model = {
CurrentPage: Page
NavigationStack: Page list
// Feature models...
}
let update msg model =
match msg with
| NavigateTo page ->
{ model with
CurrentPage = page
NavigationStack = model.CurrentPage :: model.NavigationStack
}, [], None
| NavigateBack ->
match model.NavigationStack with
| prevPage :: rest ->
{ model with
CurrentPage = prevPage
NavigationStack = rest
TaskDetailModel = None
}, [], None
| [] -> model, [], NoneFeatures communicate through:
- Navigation Messages: Returned from update function as Option
- Shared Model Data: Passed through the Root model
- Command Messages: For async operations
Example:
// In TaskList feature
let update msg model =
match msg with
| TaskClicked taskId ->
model, [], Some (NavigateToTaskDetail taskId)
// In Root
let update msg model =
match msg with
| TaskListMsg tlMsg ->
let taskListModel, cmds, navOpt = TaskList.State.update tlMsg model.TaskListModel
let model' = { model with TaskListModel = taskListModel }
match navOpt with
| Some (TaskList.NavigateToTaskDetail taskId) ->
model', cmds, Some (NavigateTo (TaskDetailPage (Some taskId)))
| Some TaskList.NavigateToNewTask ->
model', cmds, Some (NavigateTo (TaskDetailPage None))
| None ->
model', cmds, NoneThe sample uses an in-memory store to simulate a backend:
module MockDataStore =
let private tasks = ResizeArray<MTask>()
let getAllTasks() = tasks |> Seq.toList
let addTask task = tasks.Add(task); task
let updateTask task = (* implementation *)The API layer adds async simulation:
module TaskApi =
let loadTasks() = async {
do! Async.Sleep(300) // Simulate network delay
return MockDataStore.getAllTasks()
}Benefits:
- Easy to replace with real HTTP calls
- Testable without external dependencies
- Consistent async patterns
The RadialSlider demonstrates integrating SkiaSharp controls (all in F#):
-
F# SkiaSharp Control (
SkRadialSliderinRadialSlider.fs):- Inherits from
SKCanvasView - Implements touch handling and rendering
- Defines bindable properties
- Inherits from
-
F# Wrapper (
CustomRadialSliderinRadialSlider.fs):- Wraps the SkiaSharp control
- Exposes CLIEvent for Fabulous
-
Fabulous Bindings:
- Widget registration
- Attribute definitions
- Builder methods
type IRadialSlider = inherit IFabView
module RadialSlider =
let WidgetKey = Widgets.register<CustomRadialSlider>()
let ValueChanged = Attributes.defineBindableWithEvent (...)
[<AutoOpen>]
module RadialSliderBuilder =
type View with
static member RadialSlider(value, onChanged) =
WidgetBuilder<'msg, IRadialSlider>(
RadialSlider.WidgetKey,
RadialSlider.ValueChanged.WithValue(...)
)Validate the model after updates:
let validate model =
{ model with
Title =
if model.Title.Length > MaxLength then
model.Title.Substring(0, MaxLength)
else
model.Title
}
let update msg model =
let model', cmds, nav = updateInternal msg model
validate model', cmds, navCombine multiple commands:
let update msg model =
match msg with
| SaveAndNavigate ->
model,
[ SaveCmd; LoadCmd ],
Some NavigateBackHandle errors gracefully:
type Msg =
| DataLoaded of Result<Data, string>
let mapCmdMsg = function
| LoadData ->
Cmd.ofAsyncMsg (async {
try
let! data = Api.loadData()
return DataLoaded (Ok data)
with ex ->
return DataLoaded (Error ex.Message)
})Track async operations:
type Model = {
Data: Data option
IsLoading: bool
Error: string option
}
let update msg model =
match msg with
| StartLoad ->
{ model with IsLoading = true; Error = None },
[ LoadDataCmd ],
None
| DataLoaded (Ok data) ->
{ model with Data = Some data; IsLoading = false },
[],
None
| DataLoaded (Error err) ->
{ model with IsLoading = false; Error = Some err },
[],
None- Keep view functions pure
- Avoid expensive computations in view
- Use memoization for complex calculations
// Bad: Filtering in view
for task in model.Tasks |> List.filter filterFn do
taskItem task
// Good: Pre-filter in model or update
let filteredTasks = State.getFilteredTasks model
for task in filteredTasks do
taskItem taskBatch related commands:
let update msg model =
match msg with
| ComplexAction ->
model,
[ Cmd1; Cmd2; Cmd3 ], // All execute in parallel
NoneKeep models focused:
// Good: Separate concerns
type TaskListModel = {
Tasks: Task list
Filter: TaskFilter
}
type TaskDetailModel = {
TaskId: TaskId option
Title: string
Description: string
Priority: float
IsLoading: bool
IsSaving: bool
OriginalTask: MTask option
}
// Bad: Everything in one model
type AppModel = {
AllTasks: Task list
SelectedTask: Task option
Filter: TaskFilter
IsEditing: bool
// ... too many fields
}Test update functions easily:
[<Test>]
let ``Adding task updates model correctly`` () =
let model = { Tasks = [] }
let model', _, _ = update (AddTask "Test") model
Assert.AreEqual(1, model'.Tasks.Length)
Assert.AreEqual("Test", model'.Tasks.[0].Title)Test command generation:
[<Test>]
let ``Save task generates correct command`` () =
let model = { Title = "Test" }
let _, cmds, _ = update SaveTask model
Assert.IsTrue(List.contains (SaveTaskCmd _) cmds)Test navigation events:
[<Test>]
let ``Task click generates navigation event`` () =
let model = { Tasks = [task] }
let _, _, navOpt = update (TaskClicked taskId) model
Assert.AreEqual(Some (NavigateToDetail taskId), navOpt)type Page =
| ListPage
| DetailPage of ItemId
// Navigation handled in Root
let update msg model =
match msg with
| ListMsg (ItemSelected id) ->
model, [], Some (NavigateTo (DetailPage id))type ValidationError =
| Required of field: string
| TooLong of field: string * max: int
let validate model =
[
if String.IsNullOrWhiteSpace(model.Title) then
yield Required "Title"
if model.Title.Length > 100 then
yield TooLong ("Title", 100)
]
let update msg model =
match msg with
| Save ->
let errors = validate model
if errors.IsEmpty then
model, [ SaveCmd ], None
else
{ model with Errors = errors }, [], Nonetype Msg =
| RequestDelete of TaskId
| ConfirmDelete of TaskId
| CancelDelete
let update msg model =
match msg with
| RequestDelete id ->
{ model with PendingDelete = Some id }, [], None
| ConfirmDelete id ->
{ model with PendingDelete = None },
[ DeleteTaskCmd id ],
None
| CancelDelete ->
{ model with PendingDelete = None }, [], NoneTo use real backend:
- Replace
MockDataStorewith HTTP client:
module TaskApi =
let httpClient = new HttpClient()
let loadTasks() = async {
let! response = httpClient.GetAsync("/api/tasks") |> Async.AwaitTask
let! json = response.Content.ReadAsStringAsync() |> Async.AwaitTask
return JsonSerializer.Deserialize<Task list>(json)
}- Add authentication:
type Container = {
Api: ITaskApi
Auth: IAuthService
}
let mapCmdMsg container cmdMsg =
match cmdMsg with
| LoadTasks ->
Cmd.ofAsyncMsg (async {
let! token = container.Auth.getToken()
let! tasks = container.Api.loadTasks(token)
return TasksLoaded tasks
})This architecture provides:
- Maintainability: Clear separation of concerns
- Testability: Pure functions are easy to test
- Scalability: Feature-based structure scales well
- Type Safety: F# prevents many runtime errors
- Predictability: Unidirectional data flow is easy to reason about
The Task Manager sample demonstrates these principles in a simple, focused application that can serve as a template for more complex projects.