Hacn is a DSL for creating React components using Fable, an F# to Javascript compiler and F# computation expressions. It's intended to make it easy to write complex interactive components without using callbacks.
If you're familiar with functional programming languages like Haskell and Scala you can think of it as an attempt to write a React monad, though it probably isn't technically a monad. It draws inspiration from React Hooks, algebraic effects, Redux sagas and is similar in concept to crank js.
It's written on top of react to make it possible to easily integrate with existing components and potentially to integrate into existing projects.
You can see TodoMVC written in it here
To install into your F# project:
dotnet add package Hacn
Hacn uses the type Hacn.Types.Operation
to represent actions and effects that control the execution and rendering of a hacn react component. It's easiest to think of this as being like the Promise
type in javascript, with some extras to handle things like rendering.
In the same was as async/await is used to combine promises in javascript, Hacn uses F# computation expressions to sequence operations. If you're not familar with how computation expressions work it might be helpful to read a tutorial on async programming in F#, since Hacn shares some of the same concepts.
Unlike async/await, control flow in a Hacn component isn't simply linear with one operation following another. Operations have state and can signal that they have changed causing all the operations after them to re-execute. Another way of thinking about this is as a kind of implicit goto.
To create a component you use the react { ... }
expression builder syntax. Hacn components can be included in regular Fable React components:
module User
open Hacn.Core
open Hacn.Operations
let Element =
react {
let! props = Props
do! Render(
Html.div [
prop.text props.Message
]
)
}
let User =
React.functionComponent(
fun props ->
Html.div [
Element {Message = "Hello"}
]
)
Props
and Render
are operations and handle rendering, capturing results, props, state, timeouts and eventually things like fetches. They implement the Perform
interface from the Operation
typed union, see the section on writing operations below.
Built in operations are currently defined in Hacn.Operations
, work is still ongoing to document all these properly and expand them to include things like data fetching.
The Props
operation handles react props, basically it restarts the sequence of operations from the point where Props
is used when props changes.
module Element
open Hacn.Core
open Hacn.Operations
type ElementProps = [
Message: string
]
let Element =
react {
let! props = Props
}
Props uses a shallow comparison to compare fields, you can perform your own comparison by implementing an operation that uses the PerformProps
interface. See the section on writing operations below.
Hacn uses a library called Feliz for html, basic rendering is handled using the Render
operation:
module Element
open Hacn.Core
open Hacn.Operations
let Element =
react {
do! Render(
Html.div [
prop.text "Hello World!"
]
)
}
It's also possible to capture dom events and "return" them from the Render
operation into the sequence of events:
module Element
open Hacn.Core
open Hacn.Operations
let Element =
react {
let! newValue = Render(
Html.div [
prop.text value
prop.children [
Html.input [
prop.captureValueChange
]
]
]
)
console.log newValue
}
Capture can also be performced manually using the RenderCapture
operation.
module Element
open Hacn.Core
open Hacn.Operations
let Element =
react {
let! newValue = RenderCapture(
fun capture ->
Html.div [
prop.text value
prop.children [
Html.input [
prop.onChange (
fun (keyEvent: Browser.Types.Event) ->
let inputElement = box keyEvent.target :?> HTMLInputElement
capture (inputElement.value)
)
]
]
]
)
console.log newValue
}
Hacn always rerenders the react element that was rendered, so after capturing the results the Html.input returning onChange events will be rendered again.
The State
operation works similarly to the useState
react hook, except it returns a function to create an operation which updates the state:
module Element
open Hacn.Core
open Hacn.Operations
let Element =
react {
let! value, updateValue = State "Start"
let! newValue = RenderCapture(
fun capture ->
Html.div [
prop.text value
prop.children [
Html.input [
prop.onChange (
fun (keyEvent: Browser.Types.Event) ->
let inputElement = box keyEvent.target :?> HTMLInputElement
capture (inputElement.value)
)
]
]
]
)
if newValue = "End" then
do! updateValue "Done"
}
NB: setting the state always triggers the sequence to restart from the point that state was used, regardless of whether the value was changed.
The Call
operation can be used to call functions that are passed in from parent components and also any kind of effect that doesn't update operation state or trigger rerenders.
Hacn component props need to implement the equality interface, this isn't automatically generated for props that contain functions so you have to implement it yourself:
[<CustomEquality; NoComparison>]
type TestProps = { TestFunc: string -> unit }
with
override _.Equals __ = false
override _.GetHashCode() = 1
let Test =
react {
let! props = Props
do! Call (fun () -> (props.TestFunc "Test") )
}
NB: the implementation of equality isn't actually used by Fable, but is required for type checking.
The following features and operations need some additional work and testing.
The react
expression builder returns a react component rather than an Operation
, which means you can't easily compose sequences of operations.
There is a separate hacn
builder that allows creation of composable operations:
let clickBlocker () = hacn {
do! Render Html.div [
prop.text "Continue?"
prop.captureClick ()
]
}
let App =
react {
let! props = Props
do! clickBlocker ()
do! Render Html.div [
prop.testId "clicked"
prop.text "Element Clicked!"
]
}
Conditionals can be used:
type CombineProps = {
ShowBlocker: bool
}
let App =
react {
let! props = Props
if props.ShowBlocker then
do! Render Html.div [
prop.text "Continue?"
prop.captureClick ()
]
do! Render Html.div [
prop.text "Done!"
]
}
There is some support for catching errors in child components and in operations. Check the section below for how to trigger errors in operations.
type TestException(message:string) =
inherit Exception(message, null)
let errorComponent = React.functionComponent (
fun _ ->
raise (TestException "Something's wrong")
[]
)
let App =
react {
try
let! x = Props
do! Render errorComponent []
with
| e ->
do! Render Html.div [
prop.testId "error"
prop.text e.Exception.Message
]
}
The interface for wrting operations is defined by the Operation
typed union in Hacn.Types
. The Perform
record interface is the primary way to write operations that do things like call hooks and wrap functions like the setTimeout
function. The PerformProps
interface is used to create operations with custom props change detection logic.
All the other cases are used internally e.g. End
marks the end of the sequence of operations and Control
and ControlProps
are used internally to make typechecking work.
Each operation has an associated state associated with it that is passed into the functions of the Perform
. The operation state is currently typed as obj
(basically untyped) so it has to be cast to the type you want it to be before using it.
The Perform
type case takes a record that contains two functions, PreProcess
and GetResult
:
type PerformData<'props, 'returnType when 'props: equality> =
{
PreProcess: obj option -> obj option;
GetResult: CaptureReturn -> obj option -> PerformResult<'props, 'returnType>;
}
The PreProcess
function is mainly for operations that wrap hooks and therefore need to be run every time in the same order a Hacn component renders. The function takes the current operation state if it exists and returns an updated state if something has changed. Returning a value causes the Hacn runtime to restart the sequence of operations at that point.
As an example wrapping the useRef
hook is defined as follows:
let Ref (initialValue: 'returnType option) =
Perform({
PreProcess = fun operationState ->
let currentRef = Hooks.useRef(initialValue)
let castOperationState: 'returnType option = unbox operationState
match castOperationState with
| Some(_) -> None
| None -> Some(currentRef :> obj)
GetResult = fun _ operationState ->
let castOperationState: (('returnType option) IRefValue) option = unbox operationState
match castOperationState with
| Some(existingRef) ->
PerformContinue(
{
Element = None
Effect = None
LayoutEffect = None
},
existingRef
)
| None -> failwith "should not happen"
})
The GetResult
method is the main method for handling operation logic in Hacn. It has to handle a number of different scenarios for how operations are written, so it ends up being a bit complicated.
The two parameters it takes are capture
, which is for updating the operation state from effects and dom events. The second is the current operation state if it has been set.
The capture
parameter is a function that takes a function to update the operation state. The type of the update function is StateUpdater
and takes the current state (if any) and returns one of the following to update the operation state:
- Keep - keep the existing state as is.
- Erase - set the operation state to
None
. - Replace - update the state to the value of replace.
- SetException - used to set the exception on operations, so that they can be handled with try/with.
The return type for GetResult
is the PerformResult
typed union, which has two cases - PerformWait
which causes hacn to wait for an effect or capture to update the operation state and PerformContinue
which causes hacn to return a value to the sequence of operations.
Both cases include a record of type OperationData
, which includes several fields for controlling hacn:
- The
Element
field which is what the operation should render. - The
Effect
andLayoutEffect
fields for any side effects e.g. setTimeout. BothEffect
andLayoutEffect
work the same, withEffect
being run in auseEffect
hook andLayoutEffect
being run in auseLayoutEffect
hook. OperationState
- immediately updates the operation state without triggering a rerender - mostly used for memoization.
Effects are functions that can be used to update the operation state and goto to its location in the sequence of events. This causes its GetResult
method to be called again, possibly to return the updated result with PerformContinue
.
Effects can return a function to dispose of any resources when the sequence of operations goes to a previous operation e.g. props change and all operations forward of the Props
operation need to be reset. Typically operations set their operation state to None in dispose, though this isn't enforced by default (yet). The dispose function returns a state updater like the capture
function passed into GetResult
As an example here is the Timeout
operation:
let Timeout time =
Perform({
PreProcess = fun _ -> None;
GetResult = fun captureResult operationState ->
match operationState with
| Some(_) ->
PerformContinue(
{
Element = None;
Effect = None;
LayoutEffect = None
},
()
)
| None ->
let timeoutEffect () =
let timeoutCallback () =
captureResult Replace(() :> obj)
let timeoutID = Fable.Core.JS.setTimeout timeoutCallback time
Some(fun _ ->
Fable.Core.JS.clearTimeout timeoutID
None
)
PerformWait(
{
Element = None
Effect = Some(timeoutEffect)
LayoutEffect = None
}
)
})
- Fully document operations and architecture.
- Create website.
- User Guide.
- Implement error handling to allow try/catch
- Handle errors within Wait and WaitAny
- Preprocess for error handling
- Error handling in dispose
- Test combinations of combine/compose/try/with
- Allow returning error as value (rather than try/with)
- Graphql generation with Snowflaque
- See if operation state can be made typesafe.
- Fix type of
Operation
so that it doesn't include separate Perform/Control types. - Create compiler that takes a hacn component and compiles a hooks based element out of it.
- Paul Johnson - pj