Yeah, that’s my thought process as well. If a user is already using/wanting to use Halogen, then they need to understand a number of “complex” concepts already:
- the Monad type class hierarchy
- how to model pure/impure functions via Monads
- PureScript syntax in general
- the Halogen framework
None of the above concepts are hard to use once understood. Rather, the issue is being able to explain them in ways that people can grasp.
On initial viewing, the Halogen Modular template/example file above would definitely scare people away. However, after understanding how it works, I think you’d find it tremendously helpful.
So, here’s a brief overview:
Row polymorphism means we can make a function work on only one label of a Variant/Record without needing to worry about what else that Variant could be or that Record could have. For example
type V = Variant (first :: String | otherPossibleValuesOrMaybeNoneAtAll)
type R = Record (first :: String | otherLabelsOrMaybeNoneAtAll)
getName :: forall otherRows. { name :: String | otherRows } -> String
getName record = record.name
-- These compile
-- getName { name: "foo", age: 40 }
-- getName { age: 40, name: "foo"}
-- getName { age: 40 }
-- Similarly for Variant
By using type aliases and Type.Row.RowApply
/type (+)
, we can specify the rows used in the Variant/Record easily
import Type.Row (type (+))
type MainActionRows r = (component :: MainAction | r)
type LibraryActionRows r = (libraryName :: LibraryAction | r)
type Action =
Variant
( | MainActionRows
+ LibraryActionRows
+ ()
)
With that understanding, each main type in a Halogen component (e.g. state
, action
, query
, message
) can either be a normal “data” type (it’s not extendable/modular) or a Variant/Record type (it is extendable/modular).
State
: a state type can be one of two things: either a data-like value (e.g. data State = ...
) or a record (e.g. type State = { ... }
). If using a renderless component that needs to store and update its own state without affecting the end-user’s state (e.g. Halogen Select), then that component might do two things: first, require you to specify what the values are for its labels in the state record; and two, require you to specify some “library input” values in the initialState :: input -> state
function that it later modifies (e.g. Halogen Select specifies search :: Maybe String
as an input to that library, which it changes to search :: String
in the actual state type used via fromMaybe "" searchValue
).
In this situation, the record version is the one that is easier to use because the library can use Record.Builder
to add or modify additional labels to the state. In the above file, that’s what the { | PipelineRows }
mean. If you change input
into { | PipelineRows }
, then the library provides its own function that further modifies that record until it is { | YourStateRows + LibraryStateRows + () }
. If one was using multiple renderless components, each library could specify its own pipeline function (e.g. Builder { | PipelineRows} { | StateRows }
), which are composed together.
Action
: an Action can either be a “data” type or a Variant. If it’s a Variant, then each renderless component library can specify and handle its own actions. All you need to do is add their action rows to your Variant-based action type. Then, your handleAction
looks like this:
handleAction =
caseV
# handleLibrary1Actions
# handleLibrary2Actions
# on _mainComponent handleMainAction
-- ^ i.e. your actions, not other libraries' actions
Query
: A query an likewise be a data type or a VariantF
. If it’s a VariantF, then each renderless component library can specify and handle its own queries. All you need to do is add their query rows to your VariantF-based query type. Then, your handleQuery
looks like this:
handleQuery =
caseVF -- i.e. Data.Functor.VariantF.case_
# handleLibrary1Query
# handleLibrary2Query
# on _mainComponent handleMainQuery
-- ^ i.e. your queries, not other libraries' queries
Message
: I’ve already covered this in a prior comment above.
So, when defining your component, you will usually use the “data” types. However, once you need to use Library X, which adds its own constraints, you can then opt-in to the Variant/Record types as needed. So, if one component needs to forward child messages but doesn’t do anything else, it can opt-in to the Variant-based Message type but leave the other Halogen types as “data” types. Or, if a component needs something like Halogen Select, it might opt-in to the Record-based State type and Variant-based Action type because (in a possible future release) that’s what Halogen Select requires in order to be used upstream. Or, if you use the same queries multiple times throughout your code (e.g. query forwarding), you can specify them once in a VariantF-based query type and then reuse that query throughout your code.
Again, this is simply a different way to do things. While we could forward messages and queries using Variant/VariantF, some other mechanism might be a better solution in some situations and a worse solution in other situations.