Skip to content

Commit 733ad35

Browse files
Restate POC
1 parent 7a7de80 commit 733ad35

File tree

2 files changed

+252
-0
lines changed

2 files changed

+252
-0
lines changed

examples/Restate_Example.res

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
module Example = {
2+
module C = {
3+
type state = int
4+
5+
type action =
6+
| Increment
7+
| Decrement
8+
9+
@react.component
10+
let make = () => {
11+
let (state, send) = ReactUpdate.useReducer((state, action) =>
12+
switch action {
13+
| Increment =>
14+
ReactUpdate.UpdateWithSideEffects(
15+
state + 1,
16+
self => {
17+
Some(() => Js.log2("increment cleanup: ", self.state))
18+
},
19+
)
20+
| Decrement =>
21+
ReactUpdate.UpdateWithSideEffects(
22+
state - 1,
23+
self => {
24+
Some(() => Js.log2("decrement cleanup: ", self.state))
25+
},
26+
)
27+
}
28+
, 0)
29+
<div>
30+
{state->React.int}
31+
<button onClick={_ => send(Decrement)}> {"-"->React.string} </button>
32+
<button onClick={_ => send(Increment)}> {"+"->React.string} </button>
33+
</div>
34+
}
35+
}
36+
37+
@react.component
38+
let make = () => {
39+
let (show, setShow) = React.useState(() => true)
40+
41+
<>
42+
<button onClick={_ => setShow(v => !v)}> {React.string("Mount/demount")} </button>
43+
{show ? <C /> : React.null}
44+
</>
45+
}
46+
47+
}
48+
49+
module Exemple2 = {
50+
module C = {
51+
type state = int
52+
53+
type action =
54+
| Increment
55+
| Decrement
56+
57+
type deferredAction =
58+
| LogIncrement
59+
| LogDecrementOnlyAtCleanup
60+
61+
module DeferredAction: Restate.HasDeferredAction with type t = deferredAction = {
62+
type t = deferredAction
63+
let variantId = action =>
64+
switch action {
65+
| LogIncrement => "LogIncrement"
66+
| LogDecrementOnlyAtCleanup => "LogDecrementOnlyAtCleanup"
67+
}
68+
}
69+
70+
module RestateReducer = Restate.MakeReducer(DeferredAction)
71+
72+
let reducer = (state, action) =>
73+
switch action {
74+
| Increment =>
75+
RestateReducer.UpdateWithDeferred(
76+
state + 1,
77+
LogIncrement,
78+
)
79+
| Decrement =>
80+
RestateReducer.UpdateWithDeferred(
81+
state - 1,
82+
LogDecrementOnlyAtCleanup,
83+
)
84+
}
85+
86+
let scheduler = (state, deferredAction) =>
87+
switch deferredAction {
88+
| LogIncrement =>
89+
Js.log2("increment side effect: ", state)
90+
// Note: the state on the cleanup will the content of this scope, and
91+
// not the previous one that exist at moment of running the function.
92+
Some(() => Js.log2("increment cleanup: ", state))
93+
| LogDecrementOnlyAtCleanup =>
94+
Some(() => Js.log2("decrement cleanup: ", state))
95+
}
96+
97+
@react.component
98+
let make = () => {
99+
let (state, send, _defer) = RestateReducer.useReducer(reducer, scheduler, 0)
100+
<div>
101+
{state->React.int}
102+
<button onClick={_ => send(Decrement)}> {"-"->React.string} </button>
103+
<button onClick={_ => send(Increment)}> {"+"->React.string} </button>
104+
</div>
105+
}
106+
}
107+
108+
@react.component
109+
let make = () => {
110+
let (show, setShow) = React.useState(() => true)
111+
112+
<>
113+
<button onClick={_ => setShow(v => !v)}> {React.string("Mount/demount")} </button>
114+
{show ? <C /> : React.null}
115+
</>
116+
}
117+
}

src/Restate.res

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// PoC for Restate library: Let's make useReducer great again!
2+
// Redux like architecture with modern React effects approach.
3+
4+
// TODO: Look for a better name
5+
// HasDeferredAction "type class" should provide a way of identify for each deferred action.
6+
// By uniquely identify their variant constructor.
7+
module type HasDeferredAction = {
8+
type t
9+
let variantId: t => string
10+
}
11+
12+
module MakeReducer = (DeferredAction: HasDeferredAction) => {
13+
/** API Types **/
14+
type dispatch<'action> = 'action => unit // Reducer Trigger Function
15+
type schedule = DeferredAction.t => unit // Scheduler Trigger Function
16+
// Magic Types based on ReactUpdate library
17+
type update<'state> =
18+
| NoUpdate // no update
19+
| Update('state) // update only
20+
| UpdateWithDeferred('state, DeferredAction.t) // update and defer a deferred action
21+
| Deferred(DeferredAction.t) // no update, but defer a deferred action
22+
// Shape of Restate Reducer
23+
type self<'state, 'action> = {
24+
send: dispatch<'action>,
25+
defer: schedule,
26+
state: 'state,
27+
}
28+
// React Reducers looks like this:
29+
// type reducer<'state, 'action> = ('state, 'action) => 'state
30+
// vs Restate Reducer "reduce" function:
31+
type reducer<'state, 'action> = ('state, 'action) => update<'state>
32+
// ^ Main difference is that our reduce function is wrapped in an custom "update" type.
33+
type scheduler<'state> = ('state, DeferredAction.t) => option<unit => unit>
34+
// ^ Scheduler is like an impure reducer that group the async/side effects actions related code.
35+
// But instead of dispatch actions inmmediatly, in their case, we differ them into a queue.
36+
37+
/** Internal Types ***/
38+
// This our the library internal actions we can dispatch.
39+
// As you can see in the implementation, they act as a proxy to the user actions.
40+
type proxy<'action> =
41+
| WiredAction('action)
42+
| PushDeferred(DeferredAction.t)
43+
| PopDeferred(Belt.List.t<DeferredAction.t>)
44+
type internalState<'action, 'state> = {
45+
userState: 'state,
46+
// TODO: Ideally we should use a Queue data type. If not, maybe use Array instead.
47+
deferredActionsQueue: Belt.List.t<DeferredAction.t>
48+
}
49+
50+
// /** Usage: **/
51+
// 1. Init module;
52+
// module RestateReducer = Restate.MakeReducer(DeferredActions)
53+
// 2. Use the Hook;
54+
// RestateReduser.useReducer(reducer, scheduler, initialState)
55+
let useReducer = (
56+
reducer: reducer<'state, 'action>, // The reducer provided by the user
57+
scheduler: scheduler<'state>, // The scheduler provided by the user
58+
initialState: 'state
59+
) => {
60+
// NOTE: We must follow the rules of React about effects cleanup!
61+
// After every re-render with changed dependencies,
62+
// React will first run the cleanup function (if you provided it) with the old values,
63+
// and then run your setup function with the new values
64+
// type effect<'action> = ('action => option<unit => unit>) => unit
65+
// Ref. https://react.dev/reference/react/useEffect#parameters
66+
let cleanupFnsRef: React.ref<Belt.Map.String.t<option<unit => unit>>> = React.useRef(Belt.Map.String.empty)
67+
let ({userState, deferredActionsQueue}, internalDispatch) = React.useReducer(
68+
({userState, deferredActionsQueue} as internalState, internalAction) =>
69+
switch internalAction {
70+
| WiredAction(action) =>
71+
switch reducer(userState, action) {
72+
| NoUpdate => internalState
73+
| Update(state) => {...internalState, userState: state}
74+
| UpdateWithDeferred(state, deferredAction) => {
75+
userState: state,
76+
deferredActionsQueue: Belt.List.concat(deferredActionsQueue, list{deferredAction}),
77+
}
78+
| Deferred(deferredAction) => {
79+
...internalState,
80+
deferredActionsQueue: Belt.List.concat(deferredActionsQueue, list{deferredAction}),
81+
}
82+
}
83+
| PushDeferred(deferredAction) => {
84+
...internalState,
85+
deferredActionsQueue: Belt.List.concat(deferredActionsQueue, list{deferredAction}),
86+
}
87+
| PopDeferred(tailDeferredActions) => {
88+
...internalState,
89+
deferredActionsQueue: tailDeferredActions,
90+
}
91+
}
92+
, {userState: initialState, deferredActionsQueue: list{}}
93+
)
94+
// Obs: Actually this useEffect and the other one compose the "scheduler"
95+
React.useEffect1(() => {
96+
// CAUTION: Maybe we should run all of them in a single effect (?)
97+
// What happens if there are 2 consecutives of the same type?
98+
switch (deferredActionsQueue) {
99+
| list{deferredAction, ...queueTail} =>
100+
// 1. If there is a previous cleanup function, run it
101+
cleanupFnsRef.current
102+
->Belt.Map.String.get(DeferredAction.variantId(deferredAction))
103+
->Belt.Option.map(mPrevCleanupFn => mPrevCleanupFn->Belt.Option.map(prevCleanupFn => prevCleanupFn()))
104+
->ignore
105+
// 2. Run the deferred action
106+
let mNewCleanupFn = scheduler(userState, deferredAction) // CAUTION: Is reducerState the latest state?
107+
// 3. Update the cleanup function
108+
cleanupFnsRef.current = cleanupFnsRef.current->Belt.Map.String.set(DeferredAction.variantId(deferredAction), mNewCleanupFn)
109+
// 4. Pop the action from the queue
110+
internalDispatch(PopDeferred(queueTail))
111+
| list{} => () // Stop condition!
112+
}
113+
None
114+
}, [deferredActionsQueue])
115+
// In case of unmount, we must run all cleanup functions and empty their tracking map
116+
React.useEffect0(() => {
117+
Some(() => {
118+
cleanupFnsRef.current
119+
->Belt.Map.String.valuesToArray
120+
->Belt.Array.forEach(
121+
mCleanupFn => mCleanupFn->Belt.Option.forEach(cleanupFn => cleanupFn())
122+
)
123+
// Not needed, given after unmount the ref is destroyed by React
124+
//cleanupFnsRef.current = Belt.Map.String.empty
125+
}
126+
)
127+
}
128+
)
129+
let defer: schedule = deferredAction => internalDispatch(PushDeferred(deferredAction))
130+
let send: dispatch<'action> = action => internalDispatch(WiredAction(action))
131+
// Notice that this is the API the user will receive.
132+
// This way, we hide implementation (unsafe) tricks.
133+
(userState, send, defer)
134+
}
135+
}

0 commit comments

Comments
 (0)