miso

Thinking in miso

Step 2: Views and components

In miso there are two ways to split a UI: view functions (any function returning a View) and components (a Component with its own model, update and lifecycle). Reach for the first by default.

Draw the boxes

BookmarksApp  (Component — owns the Model)
├── searchBar      (view function: query, onInput)
├── tagSidebar     (view function: tag counts, selected tag)
├── bookmarkTable  (view function)
│   └── bookmarkRow (view function, keyed by id)
├── detailPane     (view function)
└── "add" +> addBookmarkForm  (Component — owns its draft, validation, submit state)
Boxes are functions. Only two of them are components.

When is something a Component?

A Component costs a little ceremony (its own model and action type, a key) and buys isolation. Make one when a piece of UI:

  • owns state nobody else needs — the add form's draft and validation errors are irrelevant to the table;
  • needs subscriptions or lifecycle hooks — a clock, a websocket, a third-party widget initialised in mount;
  • is reused with different props — the same avatar component mounted for each user;
  • should re-render independently — its model changes often while the parent's does not.

Otherwise write a function. bookmarkRow :: Bookmark -> View ctx Model Action is simpler than a component, is trivially testable, and re-renders as part of its parent.

bookmarkRow :: Maybe BookmarkId -> Bookmark -> View ctx Model Action
bookmarkRow selected b =
  H.tr_ [ key_ (bookmarkId b)                             -- stable identity in the list
        , HP.classList_ [ ("selected", selected == Just (bookmarkId b)) ]
        , HE.onClick (Select (bookmarkId b)) ]
    [ H.td_ [] [ text (title b) ]
    , H.td_ [] [ text (MS.intercalate ", " (tags b)) ]
    ]

Where does each piece of state live?

StateLives inBecause
bookmarks, query, filter, selectionBookmarksApp modelseveral views read it; the app owns it
the add form's draft & errorsaddBookmarkForm modelprivate; nobody else cares until submit
the currently selected tag, as seen by the formpropsthe form only reads it to pre-fill a tag
language, theme, current usercontextglobal; every component may read it

The rule of thumb: state lives in the closest common owner of everything that reads or writes it. Push it up only as far as it needs to go, and pass it down as props.

viewApp :: Ctx -> () -> Model -> View Ctx Model Action
viewApp ctx _ m =
  H.main_ []
    [ searchBar (m ^. query)
    , H.div_ [ HP.class_ "columns" ]
        [ tagSidebar (m ^. tagFilter) (tagCounts (loadedOr [] (m ^. bookmarks)))
        , bookmarkTable (m ^. selected) (visible m)
        , detailPane (selectedBookmark m)
        ]
    , mountWithProps_ "add-form" (FormProps (m ^. tagFilter)) addBookmarkForm
    ]

Next: name the actions and write update.