Thinking in miso
Step 1: Design the model
The model is the single source of truth. The goal is to make it minimal: store what the UI cannot recompute, and compute everything else inside view.
List everything the UI shows
- The list of bookmarks from the server
- The search text the user typed
- The selected tag filter
- The bookmarks that match the search and the filter
- The count next to each tag
- Which bookmark is expanded in the detail pane
- Whether we are still loading, or something failed
Now ask three questions of each item. Does it change over time? Can it be computed from something else? Is it passed in from a parent?
- The matching bookmarks and the tag counts are computed from the list, the query and the filter — not state.
- Loading and failure are states of the list itself, so model them as one type rather than three booleans.
Write it down
data Model = Model
{ _bookmarks :: Remote [Bookmark] -- what the server said (or hasn't yet)
, _query :: MisoString -- search text
, _tagFilter :: Maybe Tag -- Nothing = all
, _selected :: Maybe BookmarkId -- expanded row
} deriving (Eq, Generic)
data Remote a = Loading | Failed MisoString | Loaded a
deriving (Eq, Generic)
data Bookmark = Bookmark
{ bookmarkId :: BookmarkId, title :: MisoString, url :: MisoString, tags :: [Tag] }
deriving (Eq, Generic, FromJSON)
makeLenses ''ModelFour fields. Everything the mockup shows can be produced from them:
visible :: Model -> [Bookmark]
visible m =
[ b | Loaded bs <- [m ^. bookmarks], b <- bs
, matches (m ^. query) b
, maybe True (`elem` tags b) (m ^. tagFilter) ]
tagCounts :: [Bookmark] -> [(Tag, Int)]
tagCounts = ...Sum types beat booleans
Remote makes the impossible states ("loaded and failed") unrepresentable, and view is forced to handle every case with a case expression. The same idea applies to modes (Viewing | Editing Draft), wizards (one constructor per step) and forms (Either Errors Valid).
Eq is a feature
Every model needs an Eq instance. Derive it. It is what lets miso skip a render when nothing changed, and it is what makes tests trivial: update is a pure RWS so runEffect gives you the new model to compare against an expected value.