Skip to content

Commit ee09ec6

Browse files
authored
Merge pull request #24 from andywhite37/reducer-use-ref
Add useRef flag to useReducer to conditionally enable useRef (default is false)
2 parents 01c515c + 4aeed5f commit ee09ec6

File tree

4 files changed

+184
-164
lines changed

4 files changed

+184
-164
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
open Jest;
2+
open Expect;
3+
open ReactTestingLibrary;
4+
5+
module Counter = {
6+
type state = int;
7+
8+
let clickCount = ref(0); // tracks updates as plain old side effects
9+
10+
let initialState = 0;
11+
12+
type action =
13+
| Inc;
14+
15+
let reducer: ReludeReact.Reducer.reducer(action, state) =
16+
(state: state, action: action) => {
17+
switch (action) {
18+
| Inc =>
19+
// Note: This is not how effects are meant to be done, this is just testing some
20+
// assumptions about React's default behavior
21+
clickCount := clickCount^ + 1;
22+
Update(state + 1);
23+
};
24+
};
25+
26+
[@react.component]
27+
let make = () => {
28+
let (state, send) =
29+
ReludeReact_Reducer.useReducer(reducer, initialState);
30+
31+
<div>
32+
{React.string("Count: " ++ string_of_int(state))}
33+
<button type_="button" onClick={_ => send(Inc)}>
34+
{React.string("Increment")}
35+
</button>
36+
</div>;
37+
};
38+
};
39+
40+
41+
describe("ReludeReact_Reducer", () => {
42+
// This tests the current "expected" behavior of useReducer in terms of how it handles
43+
// unmanaged side effects. The expected behavior is that uncontrolled side effects might
44+
// run multiple times (in this case, two times). This behavior is not desirable, but according to
45+
// a tweet by Dan Abramov, it should be expected. Overall, it's discouraged to have
46+
// unmanaged side effects in the reducer, so this is not something we would normally run into
47+
// if we're using the Relude SideEffect and IO-based update commands.
48+
test("Reducer side effect might occur multiple times (so don't do side effects like this)", () => {
49+
let renderResult = render(<Counter />);
50+
renderResult |> getByText(~matcher=`Str("Increment")) |> FireEvent.click;
51+
expect(Counter.clickCount^) |> toEqual(2);
52+
});
53+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
open Jest;
2+
open Expect;
3+
4+
describe("ReludeReact_Render", () => {
5+
test("Render.ifTrue true", () => {
6+
expect(ReludeReact_Render.ifTrue(<br />, true)) |> toEqual(<br />)
7+
});
8+
9+
test("Render.ifTrue false", () => {
10+
expect(ReludeReact_Render.ifTrue(<br />, false)) |> toEqual(React.null)
11+
});
12+
});

__tests__/ReludeReact_test.re

Lines changed: 0 additions & 59 deletions
This file was deleted.

src/ReludeReact_Reducer.re

Lines changed: 119 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -40,125 +40,139 @@ let io = io => IO(io);
4040
// A reducer function takes the current state and an action and returns an update command
4141
type reducer('action, 'state) = ('state, 'action) => update('action, 'state);
4242

43-
// The react useReducer state stores the actual component state, along with a ref array of
44-
// side effects. The side effects are collected in the reducer functions, then handled
45-
// using a useEffect hook. The component should not be, nor need to be aware of the sideEffect business.
43+
// This new state type stores the caller's state along with a mutable array of effects that
44+
// need to be run at the appropriate time. The effects are given to us via the `update`
45+
// constructors like SideEffect, UpdateWithIO, IO, etc.
4646
type stateAndSideEffects('action, 'state) = {
4747
state: 'state,
4848
sideEffects: ref(array(SideEffect.t('action, 'state))),
4949
};
5050

51+
/**
52+
* Accepts a reducer function that emits `update` commands. Any updates that include side
53+
* effects (SideEffect, UpdateWithIO, etc.) will be handled by enqueuing the side effects
54+
* for execution outside of the reducer in a controlled fashion. The side effects are expected
55+
* to dispatch further reducer actions to cause state changes, etc. IO-based effects are expected
56+
* to produce actions that will be dispatched automatically.
57+
*/
5158
let useReducer = (reducer: reducer('action, 'state), initialState: 'state) => {
52-
let refReducer =
53-
React.useRef(({state, sideEffects} as stateAndSideEffects, action) => {
54-
let update = reducer(state, action);
55-
56-
switch (update) {
57-
| NoUpdate => stateAndSideEffects
58-
59-
| Update(state) => {...stateAndSideEffects, state}
60-
61-
| UpdateWithSideEffect(state, sideEffect) => {
62-
state,
63-
sideEffects:
64-
ref(
65-
Belt.Array.concat(
66-
sideEffects^,
67-
[|SideEffect.Uncancelable.lift(sideEffect)|],
68-
),
59+
// This wraps the given reducer function with the ability to capture the side effects
60+
// emitted by the various types of updates, and stick them in our mutable array of effects to run later
61+
let reducerWithSideEffects =
62+
({state, sideEffects} as stateAndSideEffects, action) => {
63+
let update = reducer(state, action);
64+
65+
switch (update) {
66+
| NoUpdate => stateAndSideEffects
67+
68+
| Update(state) => {...stateAndSideEffects, state}
69+
70+
| UpdateWithSideEffect(state, sideEffect) => {
71+
state,
72+
sideEffects:
73+
ref(
74+
Belt.Array.concat(
75+
sideEffects^,
76+
[|SideEffect.Uncancelable.lift(sideEffect)|],
6977
),
70-
}
71-
72-
| UpdateWithCancelableSideEffect(state, cancelableSideEffect) => {
73-
state,
74-
sideEffects:
75-
ref(
76-
Belt.Array.concat(
77-
sideEffects^,
78-
[|SideEffect.Cancelable.lift(cancelableSideEffect)|],
79-
),
78+
),
79+
}
80+
81+
| UpdateWithCancelableSideEffect(state, cancelableSideEffect) => {
82+
state,
83+
sideEffects:
84+
ref(
85+
Belt.Array.concat(
86+
sideEffects^,
87+
[|SideEffect.Cancelable.lift(cancelableSideEffect)|],
8088
),
81-
}
82-
83-
| SideEffect(uncancelableSideEffect) => {
84-
...stateAndSideEffects,
85-
sideEffects:
86-
ref(
87-
Belt.Array.concat(
88-
stateAndSideEffects.sideEffects^,
89-
[|SideEffect.Uncancelable.lift(uncancelableSideEffect)|],
90-
),
89+
),
90+
}
91+
92+
| SideEffect(uncancelableSideEffect) => {
93+
...stateAndSideEffects,
94+
sideEffects:
95+
ref(
96+
Belt.Array.concat(
97+
stateAndSideEffects.sideEffects^,
98+
[|SideEffect.Uncancelable.lift(uncancelableSideEffect)|],
9199
),
92-
}
93-
94-
| CancelableSideEffect(cancelableSideEffect) => {
95-
...stateAndSideEffects,
96-
sideEffects:
97-
ref(
98-
Belt.Array.concat(
99-
stateAndSideEffects.sideEffects^,
100-
[|SideEffect.Cancelable.lift(cancelableSideEffect)|],
101-
),
100+
),
101+
}
102+
103+
| CancelableSideEffect(cancelableSideEffect) => {
104+
...stateAndSideEffects,
105+
sideEffects:
106+
ref(
107+
Belt.Array.concat(
108+
stateAndSideEffects.sideEffects^,
109+
[|SideEffect.Cancelable.lift(cancelableSideEffect)|],
102110
),
111+
),
112+
}
113+
114+
| UpdateWithIO(state, ioAction) =>
115+
// The IO must have an 'action type for both the success and error channels - this
116+
// way we know that the errors have been properly handled and translated to the appropriate action.
117+
// Run the IO to get the success and error actions, then just send them.
118+
// TODO: we don't have cancelable IOs (yet?)
119+
let sideEffect: SideEffect.t('action, 'state) = (
120+
context => {
121+
ioAction
122+
|> Relude.IO.unsafeRunAsync(
123+
fun
124+
| Ok(action) => context.send(action)
125+
| Error(action) => context.send(action),
126+
);
127+
None;
103128
}
104-
105-
| UpdateWithIO(state, ioAction) =>
106-
// The IO must have an 'action type for both the success and error channels - this
107-
// way we know that the errors have been properly handled and translated to the appropriate action.
108-
// Run the IO to get the success and error actions, then just send them.
109-
// TODO: we don't have cancelable IOs (yet?)
110-
let sideEffect: SideEffect.t('action, 'state) = (
111-
context => {
112-
ioAction
113-
|> Relude.IO.unsafeRunAsync(
114-
fun
115-
| Ok(action) => context.send(action)
116-
| Error(action) => context.send(action),
117-
);
118-
None;
119-
}
120-
);
121-
{
122-
state,
123-
sideEffects:
124-
ref(
125-
Belt.Array.concat(
126-
stateAndSideEffects.sideEffects^,
127-
[|sideEffect|],
128-
),
129+
);
130+
{
131+
state,
132+
sideEffects:
133+
ref(
134+
Belt.Array.concat(
135+
stateAndSideEffects.sideEffects^,
136+
[|sideEffect|],
129137
),
130-
};
131-
132-
| IO(ioAction) =>
133-
let sideEffect: SideEffect.t('action, 'state) = (
134-
context => {
135-
ioAction
136-
|> Relude.IO.unsafeRunAsync(
137-
fun
138-
| Ok(action) => context.send(action)
139-
| Error(action) => context.send(action),
140-
);
141-
None;
142-
}
143-
);
144-
{
145-
...stateAndSideEffects,
146-
sideEffects:
147-
ref(
148-
Belt.Array.concat(
149-
stateAndSideEffects.sideEffects^,
150-
[|sideEffect|],
151-
),
138+
),
139+
};
140+
141+
| IO(ioAction) =>
142+
let sideEffect: SideEffect.t('action, 'state) = (
143+
context => {
144+
ioAction
145+
|> Relude.IO.unsafeRunAsync(
146+
fun
147+
| Ok(action) => context.send(action)
148+
| Error(action) => context.send(action),
149+
);
150+
None;
151+
}
152+
);
153+
{
154+
...stateAndSideEffects,
155+
sideEffects:
156+
ref(
157+
Belt.Array.concat(
158+
stateAndSideEffects.sideEffects^,
159+
[|sideEffect|],
152160
),
153-
};
161+
),
154162
};
155-
});
163+
};
164+
};
165+
166+
// Our new initial state is the caller's state, plus our initial empty array of effects to run
167+
let initialStateWithSideEffects = {
168+
state: initialState,
169+
sideEffects: ref([||]),
170+
};
156171

172+
// Plug our new reducer function into the React user reducer. This reducer takes the `update`s from the caller's
173+
// reducers and enqueues the side effects in a mutable array for processing below in a separate useEffect
157174
let ({state, sideEffects}, send) =
158-
React.useReducer(
159-
refReducer |> React.Ref.current,
160-
{state: initialState, sideEffects: ref([||])},
161-
);
175+
React.useReducer(reducerWithSideEffects, initialStateWithSideEffects);
162176

163177
// This registers the side effects that were emitted by the reducer in a react effect hook.
164178
// When the hook runs, it will execute all the side effects and will
@@ -183,6 +197,6 @@ let useReducer = (reducer: reducer('action, 'state), initialState: 'state) => {
183197
[|sideEffects|],
184198
);
185199

186-
// Finally, we return our initial state, and the send function for use in the component
200+
// Finally, we return our initial state, and the send function for use in the calling component
187201
(state, send);
188-
};
202+
};

0 commit comments

Comments
 (0)