Blog › ICP guides
PureScript developer on retainer: Halogen components, Effect and Aff monads, row polymorphism, Data.Codec.Argonaut, and functional frontend on monthly retainer
October 14, 2026 · ~20 min read
A PureScript Halogen SPA used for an internal analytics dashboard was losing component state three times per day. Users would click through a drill-down report, change the top-level date filter, and find the nested ChartPanel component had reset its zoom level and annotation state to defaults. The PureScript developer on retainer diagnosed the root cause: the parent component was passing the date range as a Halogen slot input, and the ChartPanel component was defined with H.component in a way that the parent treated as a new component identity on every date range change — Halogen unmounted and remounted the slot rather than calling H.receive to update the existing instance. The fix required converting the component to use H.mkComponent with an explicit receive handler in handleAction and using H.modify_ to update internal state from new input rather than relying on the parent to signal state changes via the slot. State loss events: 3/day → 0. The change was a 40-line restructuring of the component definition.
The work log entry read “fixed ChartPanel state reset, converted to mkComponent, 12h.” It names the fix and the duration, but leaves the client unable to explain to the product team why changing from H.component to H.mkComponent with a receive handler eliminates state loss — or why the diagnosis required 12 hours when the resulting diff was 40 lines. Halogen’s slot identity mechanism is not apparent from the component API surface: both H.component and H.mkComponent produce a H.Component value; the difference is in whether the component is defined with a receive case in the ComponentSpec that maps incoming input to an Action constructor. When a parent renders a slot with a new input value, Halogen compares the slot identity. If the component does not advertise a receive handler — or if the H.component-based slot is keyed in a way that the new input creates a fresh key — Halogen treats it as a new slot and remounts. The 12 hours covered tracing the parent’s slot rendering, understanding Halogen’s slot identity resolution at the virtual DOM level, identifying that the parent was implicitly keying slots by the date range value, and designing the Receive DateRange action constructor so that H.modify_ could merge the new date range into existing zoom and annotation state without discarding it. The zoom level and annotation map were preserved exactly across date range changes. None of this mechanism analysis is visible in the 40-line diff.
PureScript type system: row polymorphism, type classes, and the kind system
PureScript’s type system is Hindley-Milner extended with row polymorphism for records and effects. Row polymorphism allows functions to accept any record that contains at least a specified set of fields: getName :: forall r. { name :: String | r } -> String accepts any record with a name :: String field, regardless of what other fields the record has. The row variable r ranges over the remaining fields; { name :: String | r } is an open row type. forall quantification introduces universally quantified type variables: identity :: forall a. a -> a is polymorphic over all types; forall r. { name :: String | r } -> String is polymorphic over all record rows that extend name :: String. The kind system classifies types: Type is the kind of ordinary types (Int, String, Boolean); Row Type is the kind of record rows ((name :: String, age :: Int)); Type -> Type is the kind of type constructors like Maybe and Array that take one type argument. newtype Name = Name String defines a newtype wrapping String; newtypes have zero runtime overhead and can derive type class instances with derive newtype instance showName :: Show Name.
Type classes in PureScript are the primary abstraction mechanism. class Show a where show :: a -> String declares a type class with one method; instance showInt :: Show Int where show = ... provides the instance for Int. derive instance eqMyType :: Eq MyType derives an Eq instance automatically for types whose fields are all Eq. Functional dependencies resolve ambiguity in multi-parameter type classes: class TypeEquals a b | a -> b, b -> a where ... declares that a determines b and vice versa, allowing the compiler to infer the second parameter from the first. where clauses introduce local definitions: a function can define helper functions in a where block that are scoped to the enclosing definition and not exported. let bindings in do notation introduce local names: let result = compute x y binds result without performing any effects. do notation desugars to bind (written >>=) chains: do { x <- m; f x } desugars to m >>= \x -> f x; do { m; n } desugars to m >> n. The Monad type class governs both Effect and Aff, so the same do notation syntax works for both.
The Effect a type represents synchronous side-effectful computations that produce a value of type a: reading from Ref, writing to the DOM, generating random numbers, reading the current time. Aff a represents asynchronous computations analogous to a Promise that can fail: HTTP requests, file reads, timer-based delays. ST r a represents locally-mutable state computations that cannot escape their scope: runST :: (forall r. ST r a) -> a runs an ST computation and returns a pure value, proving the mutation was local. launchAff_ executes an Aff computation in Effect, discarding the result; runAff executes an Aff and calls a callback with the result. liftEffect :: Effect a -> Aff a embeds a synchronous effect into an asynchronous context. try :: Aff a -> Aff (Either Error a) catches any exception thrown by an Aff computation and returns it as a Left Error, enabling typed error handling. bracket :: Aff a -> (a -> Aff Unit) -> (a -> Aff b) -> Aff b provides resource management: acquire the resource, guarantee the release action runs regardless of success or failure, run the body. forkAff :: Aff a -> Aff (Fiber a) launches a concurrent computation; joinFiber :: Fiber a -> Aff a awaits its result.
Data structures in PureScript follow the same persistent-functional-data-structure design as Haskell. Data.Map is a balanced BST map with O(log n) operations: lookup :: Ord k => k -> Map k v -> Maybe v; insert :: Ord k => k -> v -> Map k v -> Map k v; delete :: Ord k => k -> Map k v -> Map k v; fromFoldable :: Ord k => Foldable f => f (Tuple k v) -> Map k v; toUnfoldable :: Unfoldable f => Map k v -> f (Tuple k v); union :: Ord k => Map k v -> Map k v -> Map k v (left-biased). Data.Maybe: fromMaybe :: a -> Maybe a -> a; maybe :: b -> (a -> b) -> Maybe a -> b; isJust; isNothing. Data.Either: either :: (a -> c) -> (b -> c) -> Either a b -> c; note :: a -> Maybe b -> Either a b converts a Maybe to Either by supplying a Left value for Nothing. Data.Traversable: traverse :: Applicative f => Traversable t => (a -> f b) -> t a -> f (t b) maps an effectful function over a traversable structure and collects the effects; sequence :: Applicative f => Traversable t => t (f a) -> f (t a) sequences a structure of effects into an effect over a structure. traverse is the idiom for making n parallel or sequential Aff requests over a list of items.
Halogen component architecture and HTTP data pipelines
Halogen is PureScript’s primary component-based UI framework. The H.Component type is the opaque component value produced by H.mkComponent; it encapsulates the component’s state type, query type, input type, and output type. The ComponentSpec record drives component behavior: initialState :: input -> state initializes state from the first input value; render :: state -> H.ComponentHTML action slots monad produces the virtual DOM; eval :: H.HalogenQ query action input ~> H.HalogenM state action slots output monad handles all component events. H.HalogenM is the monad for component actions: H.modify_ :: (state -> state) -> H.HalogenM state action slots output monad Unit updates the component state; H.get :: H.HalogenM state action slots output monad state reads the current state; H.liftEffect :: Effect a -> H.HalogenM ... and H.liftAff :: Aff a -> H.HalogenM ... embed effects. The handleAction function handles the Action sum type: one branch per constructor, each using H.HalogenM operations.
The receive field in ComponentSpec is the mechanism that distinguishes input updates from component remounts. When a parent renders a slot with a new input value and the component has a receive handler, Halogen calls receive newInput to produce an Action value and dispatches it through handleAction — the existing component instance remains mounted with its full state intact. When receive is not specified (or is Nothing), Halogen has no way to signal the existing instance about the new input; depending on slot keying, it may unmount and remount the component, calling initialState on the new input and discarding all existing state. The ChartPanel fix: add a Receive Input constructor to the Action type, set receive = Just Receive in the ComponentSpec, and handle it in handleAction with H.modify_ \st -> st { dateRange = input.dateRange } — merging only the date range while leaving zoomLevel and annotations untouched. Parent-child communication: H.tell slotProxy slotIndex query sends a query to a child without expecting a response; H.request slotProxy slotIndex query sends a query and awaits a response; H.raise output sends an output message from child to parent. handleQuery in the child component handles incoming queries from the parent and returns a Maybe a response.
HTTP data fetching in PureScript uses either purescript-fetch or purescript-affjax. With purescript-affjax: AJAX.get ResponseFormat.json url returns Aff (Either AJAX.Error (Response Foreign)); AJAX.post ResponseFormat.json url body sends a POST; AJAX.request accepts a full Request record for custom headers and methods. The response body is a Foreign value that must be decoded. Data.Codec.Argonaut is the preferred decoding library: a CA.JsonCodec a is a bidirectional codec that can both encode a to Json and decode Json to Either JsonDecodeError a. CA.object "MyType" (CA.record { field1: CA.recordField "field1" CA.string, field2: CA.recordField "field2" CA.int }) builds a codec for a record type by naming each field. CA.array CA.string builds an array codec. CA.maybe CA.int builds a codec for an optional integer field. Codec composition: codecs are composable using CA.prismaticCodec for newtypes and sum types — CA.prismaticCodec "UserId" (Just <<< UserId) (\(UserId s) -> s) CA.string builds a codec for a UserId newtype wrapping String. The ArgonautCodecs class enables automatic codec derivation for record types using derive instance argonautCodecsMyType :: ArgonautCodecs MyType.
Build tooling for PureScript centers on Spago. spago init initializes a new project with spago.dhall and packages.dhall; spago build compiles all sources; spago test runs the test suite; spago run executes the main entry point; spago bundle-app --to output/index.js produces a single-file JavaScript bundle for browser deployment. The spago.dhall configuration file declares the project name, dependencies (as a list of package names from packages.dhall), and source globs. The packages.dhall file pins package versions: let upstream = https://github.com/purescript/package-sets/releases/download/psc-0.15.15-20240618/packages.dhall pins to a specific package set. purs compile invokes the PureScript compiler directly; purs-tidy format-in-place "src/**/*.purs" formats source files with the standard formatter. trypurescript.github.io provides a browser-based REPL for quick experimentation with PureScript expressions and type class instances without a local toolchain. A retainer engagement configuring a Spago build pipeline for production includes setting up spago bundle-app with tree-shaking via the --optimize flag, integrating purs-tidy into CI, and configuring packages.dhall additions for libraries not in the upstream package set.
How HourTab tracks PureScript developer retainer hours
PureScript retainer work has the invisible-work problem in a form specific to functional frontend: the most consequential retainer tasks — Halogen slot architecture redesign, Data.Codec.Argonaut codec hierarchy design, Aff pipeline error handling — produce small diffs. Converting H.component to H.mkComponent with a Receive handler is 40 lines. Adding CA.maybe wrapping to optional codec fields is a one-function change per field. Wrapping a launchAff_ body with try and routing the Left Error to a component error state is a 10-line change. The hours are in the diagnosis: tracing Halogen’s slot identity resolution, understanding why certain input patterns trigger remount rather than receive, identifying which codec fields are optional in the API contract versus required, mapping the error propagation paths from Aff exception to component render. None of these diagnostic paths leave artifacts proportional to their complexity in the committed diff.
HourTab gives PureScript developers a public retainer-hours URL they send to clients — typically product teams running Halogen SPAs, fintech companies using PureScript for its type guarantees, or data teams using PureScript for type-safe API client generation — at the start of an engagement. For PureScript retainers, each work log entry should name the mechanism (H.component to H.mkComponent slot architecture conversion; receive handler and Receive action design; H.modify_ state merge strategy; handleQuery parent-interrogation implementation; Aff bracket resource management; try error routing; Data.Codec.Argonaut codec hierarchy for nested JSON; CA.maybe optional field handling; CA.array element codec composition; Data.Map fromFoldable pipeline design; Data.Traversable traverse for effectful collection operations), the specific component name and the slot input pattern, the architecture decision and why, and the before/after observable metric. PureScript retainers are often compared to Elm developer retainers for functional frontend work and to Haskell developer retainers for shared type-system lineage, and to F# developer retainers for functional-first production deployments. The distinction from Elm is that PureScript uses typeclasses and row polymorphism rather than Elm’s intentionally constrained type system, giving PureScript more expressive power at the cost of a steeper learning curve — and Halogen’s slot architecture is significantly more complex than Elm’s message-passing model, making retainer architecture support correspondingly more valuable. HourTab’s work log bridges the gap: the entry names the component, the slot pattern, the architecture decision, and the before/after production metric, so the client understands what the retainer accomplished without needing to understand H.HalogenQ or row polymorphism.
Track PureScript developer retainer hours without the status emails
HourTab gives PureScript developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.
See HourTab pricing →FAQ: PureScript developer retainers
What does a PureScript developer on retainer typically do?
A PureScript developer on monthly retainer covers Halogen component architecture (H.component to H.mkComponent conversion; receive handler implementation in handleAction; H.modify_ state merge from input; H.tell/H.request/H.raise communication direction design; handleQuery parent-interrogation implementation; H.liftAff/H.liftEffect effect embedding), effects system design (Aff pipeline design with launchAff_ and forkAff; bracket resource management; try error handling; liftEffect embedding; ST r a to Effect migration; fiber-based concurrency), HTTP and data fetching (purescript-affjax AJAX.get/AJAX.post pipeline design; ResponseFormat.json configuration; Data.Codec.Argonaut CA.object/CA.record/CA.recordField codec hierarchy; CA.array element codec; CA.maybe optional field handling; codec composition for sum types; ArgonautCodecs class derivation), and data structure and build tooling (Data.Map fromFoldable/toUnfoldable pipelines; Data.Traversable traverse for effectful collection operations; Spago spago.dhall configuration; spago bundle-app production bundling; purs-tidy formatting).
What PureScript work is most underlogged in a retainer?
Halogen slot architecture redesign (ChartPanel resetting state 3/day from H.component slot remount rather than receive-based update; converted to H.mkComponent with Receive action and H.modify_ state merge; state-loss events: 3/day to 0; 40-line diff; 14 to 28 hrs invisible in slot identity tracing and component architecture analysis), Data.Codec.Argonaut codec hierarchy design (12% API decode failures from CA.recordField on optional fields without CA.maybe wrapping; restructured to CA.maybe for all optional fields; decode failures: 12% to 0%; 8 to 22 hrs invisible in optional field identification across 4 response shapes), and Aff pipeline error handling (7 blank dashboard incidents in 2 weeks from uncaught Aff exceptions surfacing in launchAff_ without try wrappers; restructured with try and bracket; blank dashboard incidents: 7 to 0; 10 to 20 hrs invisible in error propagation design and Left/Right routing).
What are typical PureScript developer retainer rates?
Entry-level PureScript developers (1–2 years, PureScript syntax, basic data type definitions, type class instances, do notation for Effect and Aff, basic H.mkComponent definitions, core PureScript libraries) bill at $80–$135/hr. Mid-level PureScript engineers (2–4 years, Halogen slot architecture distinguishing remount from receive, Aff pipeline design with bracket and try, Data.Codec.Argonaut codec hierarchy composition, row polymorphism for flexible record APIs, newtype deriving strategy, functional dependency design for multi-parameter type classes) bill at $130–$230/hr. Senior PureScript architects (4–8 years, full Halogen application architecture with complex parent-child slot trees, concurrent Aff fiber design with forkAff, large-scale codec hierarchy design for API response versioning, row polymorphism-based generic programming, PureScript-to-JavaScript FFI design, performance optimization via Data.List vs Data.Array selection, production Spago bundle pipeline configuration) bill at $185–$335/hr. Monthly retainer ranges: $2,800–$6,000/mo for advisory retainers (15–25 hrs), $9,000–$23,000/mo for full functional frontend engagements.
What should a PureScript developer retainer agreement include?
A PureScript developer retainer agreement should specify: Halogen component scope (H.component vs H.mkComponent architecture audit; receive handler implementation; H.modify_ state merge design; slot query and output communication with H.tell/H.request/H.raise; handleQuery implementation; H.liftAff/H.liftEffect embedding), effects system scope (Aff pipeline design with launchAff_ and forkAff; bracket resource management; try error handling; liftEffect embedding; ST r a to Effect migration; fiber-based concurrency design), HTTP and decoding scope (purescript-affjax pipeline design; ResponseFormat configuration; Data.Codec.Argonaut CA.object/CA.record/CA.recordField/CA.array codec design; CA.maybe optional field handling; codec composition for sum types; ArgonautCodecs derivation), data structure scope (Data.Map pipeline design; Data.Either note/fromRight error propagation; Data.Traversable traverse/sequence for effectful operations; Data.Array vs Data.List performance selection), build tooling scope (Spago spago.dhall configuration; packages.dhall package management; spago bundle-app production bundling; purs-tidy formatting; purs compile direct compilation), and hour logging format (component name; slot input pattern; receive handler strategy; before/after metric; PureScript compiler version and Spago version).
How should PureScript developer retainer hours be logged?
Log each PureScript retainer session with: advisory category (Halogen slot architecture: H.component to H.mkComponent conversion; receive handler in handleAction; H.modify_ state merge from input; H.tell/H.request/H.raise communication direction; handleQuery parent-interrogation; Aff pipeline: launchAff_ and forkAff concurrency structure; bracket resource management; try error handling and Left/Right routing; liftEffect embedding; fiber-based concurrent fetching; Data.Codec.Argonaut: CA.object/CA.record/CA.recordField composition; CA.array element codec; CA.maybe optional field handling; ArgonautCodecs derivation; sum type codec composition; HTTP pipeline: purescript-affjax AJAX.get/AJAX.post; ResponseFormat.json configuration; data structure: Data.Map fromFoldable/toUnfoldable; Data.Traversable traverse for effectful collection transforms; Data.Array vs Data.List selection; row polymorphism: open row record API design; newtype deriving strategy; build tooling: Spago spago.dhall configuration; spago bundle-app production output), specific component name and slot input pattern (parent passed DateRange as H.Slot input and ChartPanel was defined with H.component — Halogen treated a new DateRange value as a new component identity and unmounted/remounted rather than calling receive to update existing instance state), conversion strategy and why (converted to H.mkComponent with Receive DateRange action; handleAction Receive branch calls H.modify_ to merge new date range into existing state preserving zoom level and annotation map), and before/after observable metric (state-loss events per day: 3 to 0; decode failures: 12% to 0%; blank dashboard incidents in 2 weeks: 7 to 0). Include PureScript compiler version, Spago version, and purescript-halogen version.