Slide 1

Slide 1 text

Migrating a large Reason+React codebase to hooks

Slide 2

Slide 2 text

Co-founder & podcast host @ Putain de Code ! Lead front-end developer @ BeOp Matthias Le Brun @bloodyowl

Slide 3

Slide 3 text

BeOp Conversational formats for advertising and editorial usage. Contextual targeting : cookieless

Slide 4

Slide 4 text

Putain de Code ! A French blog and podcast talking about development, open to contribution and peer-reviewed

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

Context The ReasonReact API changed

Slide 7

Slide 7 text

New API motivation Zero-cost bindings

Slide 8

Slide 8 text

Before: Record API !/* still in Greeting.re #*/ let component = ReasonReact.statelessComponent("Greeting"); let make = (~name, _children) %=> { ''...component, !/* spread the template's other defaults into here #*/ render: _self %=>
{ReasonReact.string(name)} )
};

Slide 9

Slide 9 text

[@react.component] let make = (~name, ~children) %=> {
{React.string("Hello, " #++ name)} )
}; After: Function API

Slide 10

Slide 10 text

Before: JS !/* still in Greeting.re #*/ let component = ReasonReact.statelessComponent("Greeting"); let make = (name, children) %=> ({ initialState: component.initialState, didMount: component.didMount, !/* ''... ~10 more #*/ render: _self %=> React.createElement("div", name) });

Slide 11

Slide 11 text

let make = (Props) %=> { let name = Props.name; let children = Props.children; return React.createElement("div", "Hello, " + name); }; After: JS

Slide 12

Slide 12 text

Before /* State declaration */ type state = { count: int, }; /* Action declaration */ type action = | Click; /* Component template declaration. Needs to be **after** state and action declarations! */ let component = ReasonReact.reducerComponent("Example"); /* greeting and children are props. `children` isn't used, therefore ignored. We ignore it by prepending it with an underscore */ let make = (~greeting, _children) => { /* spread the other default fields of component here and override a few */ ...component, initialState: () => {count: 0}, /* State transitions */ reducer: (action, state) => switch (action) { | Click => ReasonReact.Update({count: state.count + 1}) }, render: self => { let message = "You've clicked this " ++ string_of_int(self.state.count) ++ " times(s)";
self.send(Click))> (ReasonReact.string(message))
; }, };

Slide 13

Slide 13 text

After [@react.component] let make = (~name) => { let (count, setCount) = React.useState(() => 0);

{React.string(name ++ " clicked " ++ string_of_int(count) ++ " times")}

setCount(_ => count + 1)}> {React.string("Click me")}
};

Slide 14

Slide 14 text

Before (JSX) ReasonReact.element(Component(prop1, prop2)) let element = component %=> { React.createElement(component.jsClass) }; (simplified approximation) !/* still in Greeting.re #*/ let component = ReasonReact.statelessComponent("Greeting"); let make = (~name, _children) %=> { ''...component, !/* spread the template's other defaults into here render: _self %=>
{ReasonReact.string(name)} )
};

Slide 15

Slide 15 text

After (JSX) React.createElement(Component, {prop1, prop2})

Slide 16

Slide 16 text

Official solution Wrap everything → append the wrapper to every file

Slide 17

Slide 17 text

Other solution Rewrite everything → leverage hooks to implement legacy features

Slide 18

Slide 18 text

Previous ReasonReact API included updates: •Update(state) •UpdateWithSideEffects(state, self %=> unit) •SideEffects(self %=> unit) •NoUpdate

Slide 19

Slide 19 text

React can (and will) cancel effects and updates 1. You set the state 2. React cancels update 3. React reapplies update

Slide 20

Slide 20 text

How do we register side- effects so that they run after the state update, if they're supposed to be cancellable?

Slide 21

Slide 21 text

Using a mutation trick

Slide 22

Slide 22 text

type ref('value) = { mutable contents: 'value, }; ref(1) ''=== ref(1) → false let x = ref(1); x .:= 2; x ''=== x → true

Slide 23

Slide 23 text

let myRef = { contents: value; }; myRef.value = "bar"; Preserves referential equality even if the value changes

Slide 24

Slide 24 text

type fullState('action, 'state) = { state: 'state, sideEffects: ref(array(self('action, 'state) %=> unit)), };

Slide 25

Slide 25 text

switch (reducer(action, state)) { | NoUpdate %=> fullState | Update(state) %=> {''...fullState, state} | UpdateWithSideEffects(state, sideEffect) %=> { state, sideEffects: ref(Array.concat(sideEffects^, [|sideEffect|])), } | SideEffects(sideEffect) %=> { ''...fullState, sideEffects: ref(Array.concat(fullState.sideEffects^, [|sideEffect|])), } }

Slide 26

Slide 26 text

switch (reducer(action, state)) { | NoUpdate %=> fullState | Update(state) %=> {''...fullState, state} | UpdateWithSideEffects(state, sideEffect) %=> { state, sideEffects: ref(Array.concat(sideEffects^, [|sideEffect|])), } | SideEffects(sideEffect) %=> { ''...fullState, sideEffects: ref(Array.concat(fullState.sideEffects^, [|sideEffect|])), } }

Slide 27

Slide 27 text

React.useEffect1( () %=> { if (Array.length(sideEffects^) > 0) { Array.forEach(sideEffects^, func %=> func({state, send})); sideEffects .:= ['||]; }; None; }, [|sideEffects|], );

Slide 28

Slide 28 text

React.useEffect1( () %=> { if (Array.length(sideEffects^) > 0) { Array.forEach(sideEffects^, func %=> func({state, send})); sideEffects .:= ['||]; }; None; }, [|sideEffects|], );

Slide 29

Slide 29 text

React.useEffect1( () %=> { if (Array.length(sideEffects^) > 0) { Array.forEach(sideEffects^, func %=> func({state, send})); sideEffects .:= ['||]; }; None; }, [|sideEffects|], );

Slide 30

Slide 30 text

ReactUpdate useReducer

Slide 31

Slide 31 text

No content

Slide 32

Slide 32 text

ReactCompat useRecordApi

Slide 33

Slide 33 text

type component('state, 'initialState, 'action) = { willReceiveProps: self('state, 'action) %=> 'state, willUnmount: self('state, 'action) %=> unit, didUpdate: oldNewSelf('state, 'action) %=> unit, shouldUpdate: oldNewSelf('state, 'action) %=> bool, willUpdate: oldNewSelf('state, 'action) %=> unit, didMount: self('state, 'action) %=> unit, initialState: unit %=> 'initialState, reducer: ('action, 'state) %=> update('state, 'action), render: self('state, 'action) %=> React.element, } and update('state, 'action) = | NoUpdate | Update('state) | SideEffects(self('state, 'action) %=> unit) | UpdateWithSideEffects('state, self('state, 'action) %=> unit) and self('state, 'action) = { handle: 'payload. (('payload, self('state, 'action)) %=> unit, 'payload) %=> unit, state: 'state, send: 'action %=> unit, onUnmount: (unit %=> unit) %=> unit, } and oldNewSelf('state, 'action) = { oldSelf: self('state, 'action), newSelf: self('state, 'action), }; 11/** This is not exposed, only used internally so that useReducer can return side-effects to run later. #*/ type fullState('state, 'action) = { sideEffects: ref(array(self('state, 'action) %=> unit)), state: ref('state), }; let useRecordApi = componentSpec %=> { open React.Ref; let initialState = React.useMemo0(componentSpec.initialState); let unmountSideEffects = React.useRef(['||]); let ({state, sideEffects}, send) = React.useReducer( (fullState, action) %=> 11/** Keep fullState.state in a ref so that willReceiveProps can alter it. It's the only place we let it be altered. Keep fullState.sideEffects so that they can be cleaned without a state update. It's important that the reducer only **creates new refs** an doesn't alter them, otherwise React wouldn't be able to rollback state in concurrent mode. #*/ ( switch (componentSpec.reducer(action, fullState.state^)) { | NoUpdate %=> fullState !/* useReducer returns of the same value will not rerender the component #*/ | Update(state) %=> {''...fullState, state: ref(state)} | SideEffects(sideEffect) %=> { ''...fullState, sideEffects: ref( Js.Array.concat(fullState.sideEffects^, [|sideEffect|]), ), } | UpdateWithSideEffects(state, sideEffect) %=> { sideEffects: ref( Js.Array.concat(fullState.sideEffects^, [|sideEffect|]), ), state: ref(state), } } ), {sideEffects: ref(['||]), state: ref(initialState)}, ); 11/** This is the temp self for willReceiveProps #*/ let rec self = { handle: (fn, payload) %=> fn(payload, self), send, state: state^, onUnmount: sideEffect %=> Js.Array.push(sideEffect, unmountSideEffects)->current))->ignore, }; let upToDateSelf = React.useRef(self); let hasBeenCalled = React.useRef(false); 11/** There might be some potential issues with willReceiveProps, treat it as it if was getDerivedStateFromProps. #*/ state .:= componentSpec.willReceiveProps(self); let self = { handle: (fn, payload) %=> fn(payload, upToDateSelf)->current), send, state: state^, onUnmount: sideEffect %=> Js.Array.push(sideEffect, unmountSideEffects)->current))->ignore, }; let oldSelf = React.useRef(self); let _mountUnmountEffect = React.useEffect0(() %=> { componentSpec.didMount(self); Some( () %=> { Js.Array.forEach(fn %=> fn(), unmountSideEffects)->current); !/* shouldn't be needed but like - better safe than sorry? #*/ unmountSideEffects)->setCurrent(['||]); componentSpec.willUnmount(upToDateSelf)->React.Ref.current); }, ); }); let _didUpdateEffect = React.useEffect(() %=> { if (hasBeenCalled)->current) { componentSpec.didUpdate({oldSelf: oldSelf)->current, newSelf: self}); } else { hasBeenCalled)->setCurrent(true); }; oldSelf)->setCurrent(self); None; }); 11/** Because sideEffects are only added through a **new** ref, we can use the ref itself as the dependency. This way the effect doesn't re-run after a cleanup. #*/ React.useEffect1( () %=> { if (Js.Array.length(sideEffects^) > 0) { Js.Array.forEach(func %=> func(self), sideEffects^); sideEffects .:= ['||]; }; None; }, [|sideEffects|], ); let mostRecentAllowedRender = React.useRef(React.useMemo0(() %=> componentSpec.render(self))); upToDateSelf)->setCurrent(self); if (hasBeenCalled)->current 1&& componentSpec.shouldUpdate({ oldSelf: oldSelf)->current, newSelf: self, })) { componentSpec.willUpdate({oldSelf: oldSelf)->current, newSelf: self}); mostRecentAllowedRender)->setCurrent(componentSpec.render(self)); }; mostRecentAllowedRender)->current; };

Slide 34

Slide 34 text

type component('state, 'initialState, 'action) = { willReceiveProps: self('state, 'action) %=> 'state, willUnmount: self('state, 'action) %=> unit, didUpdate: oldNewSelf('state, 'action) %=> unit, shouldUpdate: oldNewSelf('state, 'action) %=> bool, willUpdate: oldNewSelf('state, 'action) %=> unit, didMount: self('state, 'action) %=> unit, initialState: unit %=> 'initialState, reducer: ('action, 'state) %=> update('state, 'action), render: self('state, 'action) %=> React.element, } and update('state, 'action) = | NoUpdate | Update('state) | SideEffects(self('state, 'action) %=> unit) | UpdateWithSideEffects('state, self('state, 'action) %=> unit) and self('state, 'action) = { handle: 'payload. (('payload, self('state, 'action)) %=> unit, 'payload) %=> unit, state: 'state, send: 'action %=> unit, onUnmount: (unit %=> unit) %=> unit, } and oldNewSelf('state, 'action) = { oldSelf: self('state, 'action), newSelf: self('state, 'action), }; 11/** This is not exposed, only used internally so that useReducer can return side-effects to run later. #*/ type fullState('state, 'action) = { sideEffects: ref(array(self('state, 'action) %=> unit)), state: ref('state), }; let useRecordApi = componentSpec %=> { open React.Ref; let initialState = React.useMemo0(componentSpec.initialState); let unmountSideEffects = React.useRef(['||]); let ({state, sideEffects}, send) = React.useReducer( (fullState, action) %=> 11/** Keep fullState.state in a ref so that willReceiveProps can alter it. It's the only place we let it be altered. Keep fullState.sideEffects so that they can be cleaned without a state update. It's important that the reducer only **creates new refs** an doesn't alter them, otherwise React wouldn't be able to rollback state in concurrent mode. #*/ ( switch (componentSpec.reducer(action, fullState.state^)) { | NoUpdate %=> fullState !/* useReducer returns of the same value will not rerender the component #*/ | Update(state) %=> {''...fullState, state: ref(state)} | SideEffects(sideEffect) %=> { ''...fullState, sideEffects: ref( Js.Array.concat(fullState.sideEffects^, [|sideEffect|]), ), } | UpdateWithSideEffects(state, sideEffect) %=> { sideEffects: ref( Js.Array.concat(fullState.sideEffects^, [|sideEffect|]), ), state: ref(state), } } ), {sideEffects: ref(['||]), state: ref(initialState)}, ); 11/** This is the temp self for willReceiveProps #*/ let rec self = { handle: (fn, payload) %=> fn(payload, self), send, state: state^, onUnmount: sideEffect %=> Js.Array.push(sideEffect, unmountSideEffects)->current))->ignore, }; let upToDateSelf = React.useRef(self); let hasBeenCalled = React.useRef(false); 11/** There might be some potential issues with willReceiveProps, treat it as it if was getDerivedStateFromProps. #*/ state .:= componentSpec.willReceiveProps(self); let self = { handle: (fn, payload) %=> fn(payload, upToDateSelf)->current), send, state: state^, onUnmount: sideEffect %=> Js.Array.push(sideEffect, unmountSideEffects)->current))->ignore, }; let oldSelf = React.useRef(self); let _mountUnmountEffect = React.useEffect0(() %=> { componentSpec.didMount(self); Some( () %=> { Js.Array.forEach(fn %=> fn(), unmountSideEffects)->current); !/* shouldn't be needed but like - better safe than sorry? #*/ unmountSideEffects)->setCurrent(['||]); componentSpec.willUnmount(upToDateSelf)->React.Ref.current); }, ); }); let _didUpdateEffect = React.useEffect(() %=> { if (hasBeenCalled)->current) { componentSpec.didUpdate({oldSelf: oldSelf)->current, newSelf: self}); } else { hasBeenCalled)->setCurrent(true); }; oldSelf)->setCurrent(self); None; }); 11/** Because sideEffects are only added through a **new** ref, we can use the ref itself as the dependency. This way the effect doesn't re-run after a cleanup. #*/ React.useEffect1( () %=> { if (Js.Array.length(sideEffects^) > 0) { Js.Array.forEach(func %=> func(self), sideEffects^); sideEffects .:= ['||]; }; None; }, [|sideEffects|], ); let mostRecentAllowedRender = React.useRef(React.useMemo0(() %=> componentSpec.render(self))); upToDateSelf)->setCurrent(self); if (hasBeenCalled)->current 1&& componentSpec.shouldUpdate({ oldSelf: oldSelf)->current, newSelf: self, })) { componentSpec.willUpdate({oldSelf: oldSelf)->current, newSelf: self}); mostRecentAllowedRender)->setCurrent(componentSpec.render(self)); }; mostRecentAllowedRender)->current; }; Slightly more complex

Slide 35

Slide 35 text

11/** This is the temp self for willReceiveProps #*/ let rec self = { handle: (fn, payload) %=> fn(payload, self), send, state: state^, onUnmount: sideEffect %=> Js.Array.push(sideEffect, unmountSideEffects)->current))->ignore, }; let upToDateSelf = React.useRef(self); let hasBeenCalled = React.useRef(false); 11/** There might be some potential issues with willReceiveProps, treat it as it if was getDerivedStateFromProps. #*/ state .:= componentSpec.willReceiveProps(self);

Slide 36

Slide 36 text

let self = { handle: (fn, payload) %=> fn(payload, upToDateSelf)->current), send, state: state^, onUnmount: sideEffect %=> Js.Array.push(sideEffect, unmountSideEffects)->current))->ignore, }; let oldSelf = React.useRef(self); let _mountUnmountEffect = React.useEffect0(() %=> { componentSpec.didMount(self); Some( () %=> { Js.Array.forEach(fn %=> fn(), unmountSideEffects)->current); !/* shouldn't be needed but like - better safe than sorry? #*/ unmountSideEffects)->setCurrent(['||]);

Slide 37

Slide 37 text

let _mountUnmountEffect = React.useEffect0(() %=> { componentSpec.didMount(self); Some( () %=> { Js.Array.forEach(fn %=> fn(), unmountSideEffects)->current); !/* shouldn't be needed but like - better safe than sorry? #*/ unmountSideEffects)->setCurrent(['||]); componentSpec.willUnmount(upToDateSelf)->React.Ref.current); }, ); }); let _didUpdateEffect = React.useEffect(() %=> { if (hasBeenCalled)->current) { componentSpec.didUpdate({oldSelf: oldSelf)->current, newSelf: self}); } else { hasBeenCalled)->setCurrent(true); };

Slide 38

Slide 38 text

let _didUpdateEffect = React.useEffect(() %=> { if (hasBeenCalled)->current) { componentSpec.didUpdate({oldSelf: oldSelf)->current, newSelf: self}); } else { hasBeenCalled)->setCurrent(true); }; oldSelf)->setCurrent(self); None; }); 11/** Because sideEffects are only added through a **new** ref, we can use the ref itself as the dependency. This way the effect doesn't re-run after a cleanup. #*/ React.useEffect1( () %=> { if (Js.Array.length(sideEffects^) > 0) { Js.Array.forEach(func %=> func(self), sideEffects^); sideEffects .:= ['||];

Slide 39

Slide 39 text

let mostRecentAllowedRender = React.useRef(React.useMemo0(() %=> componentSpec.render(self))); upToDateSelf)->setCurrent(self); if (hasBeenCalled)->current 1&& componentSpec.shouldUpdate({ oldSelf: oldSelf)->current, newSelf: self, })) { componentSpec.willUpdate({oldSelf: oldSelf)->current, newSelf: self}); mostRecentAllowedRender)->setCurrent(componentSpec.render(self)); }; mostRecentAllowedRender)->current; };

Slide 40

Slide 40 text

Now, let's talk about codemods

Slide 41

Slide 41 text

ASTs example: translation extractor

Slide 42

Slide 42 text

let mapper = { ''...default_mapper, expr: (mapper, item) %=> 1// Match the following patterns: 1// - `T.__("translation")` 1// - `T.__({js|translation|js)` switch (item) { | { pexp_desc: Pexp_apply( {pexp_desc: Pexp_ident({txt: Ldot(Lident("T"), "__")})}, [ ( Nolabel, { pexp_desc: Pexp_constant( Pconst_string(translation, None | Some("js")), ), }, ), ], ), } as x %=> translations .:= translations.contents 6|> SetString.add(translation); x; | anythingElse %=> default_mapper.expr(mapper, anythingElse) },

Slide 43

Slide 43 text

let mapper = { ''...default_mapper, expr: (mapper, item) %=> 1// Match the following patterns: 1// - `T.__("translation")` 1// - `T.__({js|translation|js)` switch (item) { | { pexp_desc: Pexp_apply( {pexp_desc: Pexp_ident({txt: Ldot(Lident("T"), "__")})}, [ ( Nolabel, { pexp_desc: Pexp_constant( Pconst_string(translation, None | Some("js")), ), }, ), ], ), } as x %=> translations .:= translations.contents 6|> SetString.add(translation); x; | anythingElse %=> default_mapper.expr(mapper, anythingElse) },

Slide 44

Slide 44 text

let mapper = { ''...default_mapper, expr: (mapper, item) %=> 1// Match the following patterns: 1// - `T.__("translation")` 1// - `T.__({js|translation|js)` switch (item) { | { pexp_desc: Pexp_apply( {pexp_desc: Pexp_ident({txt: Ldot(Lident("T"), "__")})}, [ ( Nolabel, { pexp_desc: Pexp_constant( Pconst_string(translation, None | Some("js")), ), }, ), ], ), } as x %=> translations .:= translations.contents 6|> SetString.add(translation); x; | anythingElse %=> default_mapper.expr(mapper, anythingElse) },

Slide 45

Slide 45 text

What our codemod needs to do

Slide 46

Slide 46 text

Stateless → Function Stateless with mount → Function with useEffect Stateless with other lifecycle → useRecordApi Stateful → useReducer Stateful with mount → useReducer with useEffect Stateful with lifecycle → useRecordApi

Slide 47

Slide 47 text

Stateless Heuristic: record contains only render - Take component function arguments - Take `render` body from the record - Create a function with arguments and render body

Slide 48

Slide 48 text

Stateless with mount Heuristic: record contains only render and didMount - Take component function arguments - Take `render` body from the record - Take didMount body - Create a function with arguments, a useEffect containing didMount body, then render body

Slide 49

Slide 49 text

Stateful Heuristic: record contains only render and reducer - Take component function arguments - Take `render` body from the record - Take `reducer` body from the record - Create a function with arguments, a ReactUpdate.useReducer containing the reducer body and render body

Slide 50

Slide 50 text

Stateful with mount Heuristic: record contains only render, reducer and didMount - Take component function arguments - Take `render` body from the record - Take `reducer` body from the record - Take didMount body - Create a function with arguments, a ReactUpdate.useReducer containing the reducer body, a useEffect containing didMount body and render body

Slide 51

Slide 51 text

Any other case Heuristic: the component is probably too risky to automatically transform - Take component function arguments - Take the record - Wrap the record in a ReactCompat.useRecordApi

Slide 52

Slide 52 text

Interface files - Component function now return a React.element - Children is now an optional prop Strategy: - Remove unnecessary type exports - Check in the implementation file if children is used

Slide 53

Slide 53 text

type state; type action; let make: ( ~a: string, array(ReasonReact.reactElement) ) %=> ReasonReact.component(state, ReasonReact.noRetainedProps, action); Interface files

Slide 54

Slide 54 text

type state; type action; let make: ( ~a: string, array(ReasonReact.reactElement) ) %=> ReasonReact.component(state, ReasonReact.noRetainedProps, action); React.element; Interface files

Slide 55

Slide 55 text

type state; type action; let make: ( ~a: string, array(ReasonReact.reactElement) ) %=> ReasonReact.component(state, ReasonReact.noRetainedProps, action); React.element; Interface files

Slide 56

Slide 56 text

type state; type action; [@react.component] let make: ( ~a: string, array(ReasonReact.reactElement) ~children: React.element, 1// or `unit` ) %=> ReasonReact.component(state, ReasonReact.noRetainedProps, action); React.element; Interface files

Slide 57

Slide 57 text

Nice to have - Interface always used to expose children, but it's not always used in the component - Action & state don't need to be exposed in the interface anymore - Submodules support

Slide 58

Slide 58 text

Caveats - Well … heuristics - willReceiveProps doesn't match 1-1 - useRecordApi doesn't cancel effects - Refs had to be changed manually - No need for functors anymore → manual conversion

Slide 59

Slide 59 text

The codemod - Source has 1345 LoC - Creates a binary you can pipe `find` into - Handled ~85/90% of the work - bloodyowl/upgrade-reason-react-esy - Tested with Rely snapshots

Slide 60

Slide 60 text

The overall migration

Slide 61

Slide 61 text

The hooks - bloodyowl/reason-react-update - bloodyowl/reason-react-compat

Slide 62

Slide 62 text

Conclusions - Migrations can have a wide surface - Codemod made the change possible in a matter of days (including all manual changes) - Having a type-system made the manual changes "stupid" (in a good way).

Slide 63

Slide 63 text

Thank you Questions?