Hi, I’ve recently been playing around with Note and Brr_note on a toy project of some moderate complexity and have run into issues with structuring my signals and events and have found myself questioning if the structure I’ve chosen is correct.
The root of the application is a S.fix
node:
S.fix initial_state (fun state ->
(* ... *)
state, state
)
The signal state
captures the main state of the application.
Now within the fix, based on the state
, I construct various UI elements which themselves may release events :
let new_project_elt, (new_project_evt : [> `NewProject] event) =
button "New project" `NewProject in
...
In order to update the state, rather than modifying the data itself, the components release events which capture the information needed to enact the desired transformation at a later point.
Then, finally, at right at the end of the fix, I merge all the emitted events using a select:
let all_evt = E.select [new_project_evt; ... ] in
Sometimes it may be the case that one event may trigger another - for example, a LoadProject
emitted by a component may require asynchronous loading of data from some separate service. In this case, we can bind events:
let load_data_evt =
all_evt
|> E.filter_map (function `LoadProject url -> Some url | _ -> None)
|> Fun.flip E.bind load_data_from_url in
(* merge it back into the all_evt stream: *)
let all_evt = E.select [
load_data_evt;
E.filter (function `LoadProject _ -> false | _ -> true) all_evt
]
Then, in order to update the state of the application based on this event, I sample the state
signal, calculate the new state, and swap the signal with a constant one that always produces the updated value:
S.sample state ~on:all_evt (fun state ev ->
match ev with
| `NewProject -> set_new_project state
| ... )
|> E.map (fun v -> S.const v)
|> S.swap state
So far, this works pretty well, however, one limitation of this is that events must be resolved within one “loop” of the S.fix, and can’t influence subsequent iterations, and this means that I have to be very careful with the order in which events are handled or end up with subtle bugs, which seems to suggest that I’m doing it wrong.
As an example of the kind of problem this causes, suppose rather than just loading projects, we also wanted to ask the user if they want to save their current project before loading a new project - in this case, we now have to handle events in a particular order:
(* first wrap load project if state is dirty *)
let load_project_evt =
S.sample ~on:load_project_evt state (fun state ev ->
if state_is_dirty state
then `AskSaveThen ev
else ev) in
(* now evaluate load project *)
let load_data_evt =
load_project_evt
|> E.filter_map (function
`LoadProject url -> Some url
| _ -> None)
|> E.bind load_data_from_url in
let other_evts =
load_project_evt
|> E.filter (function `LoadProject _ -> false | _ -> true) in
(* merge back into *)
let all_evts = E.select [load_data_evt; other_evts] in
The problem is that now my events have a strict dependence on the order in which they must be run, and if I were to swap them around, the program would be subtly incorrect (managing this would be slightly less of a hassle if events were allowed to flow across loops).
For just these two cases, the structure and reasoning is simple, but as the number of dependencies increase, tracking them becomes a hassle, and makes bugs more likely, suggesting that my structure is incorrect.
Has anyone else run into this problem? or do you use a different structure for these projects?