fsharp-frontend

heimeshoff's avatarfrom heimeshoff

Implement F# frontend using Elmish MVU architecture with Feliz for React components.Use when: "add UI", "create component", "build form", "frontend", "client-side", "user interface","view", "display", "render", "Elmish", "Feliz", "button", "input", "state management".Creates Model/Msg/update in src/Client/State.fs and views in src/Client/View.fs.Follows strict MVU pattern with RemoteData for async operations and TailwindCSS/DaisyUI for styling.

⭐1stars🔀0forks📁View on GitHub🕐Updated Dec 24, 2025

When & Why to Use This Skill

This Claude skill automates the implementation of F# frontends using the Elmish Model-View-Update (MVU) architecture and Feliz for React components. It streamlines the creation of type-safe, maintainable user interfaces by generating standardized state management logic, handling asynchronous data via the RemoteData pattern, and applying modern styling with TailwindCSS and DaisyUI. It ensures architectural consistency by organizing code into dedicated State and View modules.

Use Cases

  • Rapidly scaffolding complex web interfaces with predictable state transitions using the F# Elmish MVU pattern.
  • Creating responsive, styled UI components and interactive forms using Feliz wrappers and DaisyUI CSS classes.
  • Implementing robust asynchronous data fetching logic with standardized 'Loading', 'Success', and 'Failure' states.
  • Generating boilerplate code for Model definitions, Message types, and Update functions to accelerate frontend development in F# projects.
namefsharp-frontend
description|
Use when"add UI", "create component", "build form", "frontend", "client-side", "user interface",
allowed-toolsRead, Edit, Write, Grep

F# Frontend Implementation (Elmish + Feliz)

When to Use This Skill

Activate when:

  • User requests "add UI for X", "create component for Y"
  • Implementing client-side functionality
  • Managing application state
  • Creating interactive features
  • Project uses Elmish.React + Feliz

MVU Architecture

View (user sees UI)
    ↓ (user action)
Msg (message describing action)
    ↓
Update (pure state transition)
    ↓ (optional)
Cmd (side effects like API calls)
    ↓ (result)
Msg (result wrapped in message)
    ↓
Update → new Model
    ↓
View (re-renders with new model)

Client Types (src/Client/Types.fs)

RemoteData Pattern

module Types

type RemoteData<'T> =
    | NotAsked
    | Loading
    | Success of 'T
    | Failure of string

This represents the state of async operations:

  • NotAsked - Haven't requested yet
  • Loading - Request in progress
  • Success - Request succeeded with data
  • Failure - Request failed with error message

State Management (src/Client/State.fs)

1. Model (Application State)

module State

open Elmish
open Shared.Domain
open Types

type Model = {
    // Data from server
    Todos: RemoteData<TodoItem list>
    CurrentTodo: RemoteData<TodoItem>

    // Form inputs
    NewTodoTitle: string
    NewTodoDescription: string
    SelectedPriority: Priority

    // UI state
    IsFormVisible: bool
}

Key points:

  • Use RemoteData<'T> for async operations
  • Separate form state from loaded data
  • Include UI state (modals, dropdowns, etc.)

2. Messages (State Transitions)

type Msg =
    // Load todos
    | LoadTodos
    | TodosLoaded of Result<TodoItem list, string>

    // Load single todo
    | LoadTodo of int
    | TodoLoaded of Result<TodoItem, string>

    // Create todo
    | UpdateNewTodoTitle of string
    | UpdateNewTodoDescription of string
    | UpdateSelectedPriority of Priority
    | CreateTodo
    | TodoCreated of Result<TodoItem, string>

    // Update todo
    | CompleteTodo of int
    | TodoCompleted of Result<TodoItem, string>

    // Delete todo
    | DeleteTodo of int
    | TodoDeleted of Result<unit, string>

    // UI actions
    | ToggleForm

Pattern:

  • Action message (user intent)
  • Result message (API response)

3. Init Function

let init () : Model * Cmd<Msg> =
    let model = {
        Todos = NotAsked
        CurrentTodo = NotAsked
        NewTodoTitle = ""
        NewTodoDescription = ""
        SelectedPriority = Medium
        IsFormVisible = false
    }
    let cmd = Cmd.ofMsg LoadTodos
    model, cmd

4. Update Function

let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
    match msg with
    | LoadTodos ->
        let cmd =
            Cmd.OfAsync.either
                Api.todoApi.getAll
                ()
                (Ok >> TodosLoaded)
                (fun ex -> Error ex.Message |> TodosLoaded)
        { model with Todos = Loading }, cmd

    | TodosLoaded (Ok todos) ->
        { model with Todos = Success todos }, Cmd.none

    | TodosLoaded (Error err) ->
        { model with Todos = Failure err }, Cmd.none

    | UpdateNewTodoTitle title ->
        { model with NewTodoTitle = title }, Cmd.none

    | UpdateNewTodoDescription desc ->
        { model with NewTodoDescription = desc }, Cmd.none

    | UpdateSelectedPriority priority ->
        { model with SelectedPriority = priority }, Cmd.none

    | CreateTodo ->
        let request = {
            Title = model.NewTodoTitle
            Description =
                if String.IsNullOrWhiteSpace(model.NewTodoDescription)
                then None
                else Some model.NewTodoDescription
            Priority = model.SelectedPriority
        }
        let cmd =
            Cmd.OfAsync.either
                Api.todoApi.create
                request
                TodoCreated
                (fun ex -> Error ex.Message |> TodoCreated)
        model, cmd

    | TodoCreated (Ok _) ->
        // Reset form and reload
        { model with
            NewTodoTitle = ""
            NewTodoDescription = ""
            SelectedPriority = Medium
            IsFormVisible = false },
        Cmd.ofMsg LoadTodos

    | TodoCreated (Error err) ->
        model, Cmd.none  // Could add error to model

    | CompleteTodo id ->
        let cmd =
            Cmd.OfAsync.either
                Api.todoApi.complete
                id
                TodoCompleted
                (fun ex -> Error ex.Message |> TodoCompleted)
        model, cmd

    | TodoCompleted (Ok _) ->
        model, Cmd.ofMsg LoadTodos

    | TodoCompleted (Error _) ->
        model, Cmd.none

    | DeleteTodo id ->
        let cmd =
            Cmd.OfAsync.either
                Api.todoApi.delete
                id
                (fun _ -> Ok () |> TodoDeleted)
                (fun ex -> Error ex.Message |> TodoDeleted)
        model, cmd

    | TodoDeleted (Ok _) ->
        model, Cmd.ofMsg LoadTodos

    | TodoDeleted (Error _) ->
        model, Cmd.none

    | ToggleForm ->
        { model with IsFormVisible = not model.IsFormVisible }, Cmd.none

View Components (src/Client/View.fs)

Basic Component

module View

open Feliz
open Shared.Domain
open State
open Types

let private todoCard (todo: TodoItem) (dispatch: Msg -> unit) =
    Html.div [
        prop.className "card bg-base-100 shadow-xl"
        prop.children [
            Html.div [
                prop.className "card-body"
                prop.children [
                    Html.h2 [
                        prop.className "card-title"
                        prop.text todo.Title
                    ]

                    match todo.Description with
                    | Some desc ->
                        Html.p [
                            prop.className "text-sm text-gray-600"
                            prop.text desc
                        ]
                    | None -> Html.none

                    Html.div [
                        prop.className "badge badge-primary"
                        prop.text (string todo.Priority)
                    ]

                    Html.div [
                        prop.className "card-actions justify-end"
                        prop.children [
                            if todo.Status = Active then
                                Html.button [
                                    prop.className "btn btn-success btn-sm"
                                    prop.text "Complete"
                                    prop.onClick (fun _ -> dispatch (CompleteTodo todo.Id))
                                ]

                            Html.button [
                                prop.className "btn btn-error btn-sm"
                                prop.text "Delete"
                                prop.onClick (fun _ -> dispatch (DeleteTodo todo.Id))
                            ]
                        ]
                    ]
                ]
            ]
        ]
    ]

Form Component

let private createTodoForm (model: Model) (dispatch: Msg -> unit) =
    Html.div [
        prop.className "card bg-base-200 mb-8"
        prop.children [
            Html.div [
                prop.className "card-body"
                prop.children [
                    Html.h3 [
                        prop.className "card-title"
                        prop.text "Create New Todo"
                    ]

                    Html.input [
                        prop.className "input input-bordered w-full mb-2"
                        prop.type' "text"
                        prop.placeholder "Title"
                        prop.value model.NewTodoTitle
                        prop.onChange (UpdateNewTodoTitle >> dispatch)
                    ]

                    Html.textarea [
                        prop.className "textarea textarea-bordered w-full mb-2"
                        prop.placeholder "Description (optional)"
                        prop.value model.NewTodoDescription
                        prop.onChange (UpdateNewTodoDescription >> dispatch)
                    ]

                    Html.select [
                        prop.className "select select-bordered w-full mb-2"
                        prop.value (string model.SelectedPriority)
                        prop.onChange (fun (value: string) ->
                            let priority =
                                match value with
                                | "Low" -> Low
                                | "High" -> High
                                | "Urgent" -> Urgent
                                | _ -> Medium
                            dispatch (UpdateSelectedPriority priority))
                        prop.children [
                            Html.option [ prop.value "Low"; prop.text "Low" ]
                            Html.option [ prop.value "Medium"; prop.text "Medium" ]
                            Html.option [ prop.value "High"; prop.text "High" ]
                            Html.option [ prop.value "Urgent"; prop.text "Urgent" ]
                        ]
                    ]

                    Html.button [
                        prop.className "btn btn-primary"
                        prop.text "Create"
                        prop.onClick (fun _ -> dispatch CreateTodo)
                        prop.disabled (String.IsNullOrWhiteSpace(model.NewTodoTitle))
                    ]
                ]
            ]
        ]
    ]

RemoteData View Pattern

let private todosView (remoteData: RemoteData<TodoItem list>) (dispatch: Msg -> unit) =
    match remoteData with
    | NotAsked ->
        Html.div [
            prop.className "text-center p-8"
            prop.children [
                Html.button [
                    prop.className "btn btn-primary"
                    prop.text "Load Todos"
                    prop.onClick (fun _ -> dispatch LoadTodos)
                ]
            ]
        ]

    | Loading ->
        Html.div [
            prop.className "flex justify-center items-center p-8"
            prop.children [
                Html.span [ prop.className "loading loading-spinner loading-lg" ]
            ]
        ]

    | Success todos when todos.IsEmpty ->
        Html.div [
            prop.className "alert alert-info"
            prop.text "No todos yet. Create one above!"
        ]

    | Success todos ->
        Html.div [
            prop.className "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
            prop.children [
                for todo in todos -> todoCard todo dispatch
            ]
        ]

    | Failure error ->
        Html.div [
            prop.className "alert alert-error"
            prop.children [
                Html.span [ prop.text $"Error: {error}" ]
                Html.button [
                    prop.className "btn btn-sm"
                    prop.text "Retry"
                    prop.onClick (fun _ -> dispatch LoadTodos)
                ]
            ]
        ]

Main View

let view (model: Model) (dispatch: Msg -> unit) =
    Html.div [
        prop.className "container mx-auto p-4"
        prop.children [
            Html.h1 [
                prop.className "text-4xl font-bold mb-8"
                prop.text "Todo App"
            ]

            createTodoForm model dispatch

            todosView model.Todos dispatch
        ]
    ]

Common Patterns

Loading States

match model.Data with
| NotAsked -> Html.div "Click to load"
| Loading -> Html.span [ prop.className "loading loading-spinner" ]
| Success data -> // render data
| Failure err -> Html.div [ prop.className "alert alert-error"; prop.text err ]

Form Input

Html.input [
    prop.value model.InputValue
    prop.onChange (fun (value: string) -> dispatch (UpdateInput value))
]

Button Click

Html.button [
    prop.onClick (fun _ -> dispatch SaveData)
    prop.text "Save"
]

Conditional Rendering

if condition then
    Html.div "Show this"
else
    Html.none

Lists

Html.ul [
    prop.children [
        for item in items -> Html.li [ prop.text item.Name ]
    ]
]

TailwindCSS + DaisyUI Classes

Layout:

  • Container: container mx-auto p-4
  • Grid: grid grid-cols-3 gap-4
  • Flex: flex justify-center items-center

Components (DaisyUI):

  • Button: btn btn-primary btn-sm
  • Card: card bg-base-100 shadow-xl
  • Input: input input-bordered w-full
  • Alert: alert alert-error
  • Badge: badge badge-primary
  • Loading: loading loading-spinner loading-lg

Verification Checklist

  • RemoteData type in src/Client/Types.fs
  • API client in src/Client/Api.fs
  • Model defined in src/Client/State.fs
  • Messages defined
  • Init function
  • Update function handles all messages
  • View components in src/Client/View.fs
  • RemoteData pattern used
  • Proper error handling in views
  • TailwindCSS/DaisyUI styling

Common Pitfalls

❌ Don't:

  • Mutate model (always return new)
  • Put side effects in update (use Cmd)
  • Skip handling error states
  • Forget to dispatch messages
  • Mix logic in view functions

✅ Do:

  • Keep update pure
  • Use Cmd for side effects
  • Handle all RemoteData states
  • Use dispatch for all user actions
  • Keep views simple and composable
  • fsharp-shared - Type definitions
  • fsharp-backend - API to call
  • fsharp-tests - Test state transitions
  • /docs/02-FRONTEND-GUIDE.md - Detailed frontend guide
  • /docs/09-QUICK-REFERENCE.md - Quick templates