Migrating a large Reason+React codebase to hooks

Migrating a large Reason+React codebase to hooks

37500337ba5d2aebc962959ed83928e5?s=128

Matthias Le Brun

March 25, 2020
Tweet

Transcript

  1. Migrating a large Reason+React codebase to hooks

  2. Co-founder & podcast host @ Putain de Code ! Lead

    front-end developer @ BeOp Matthias Le Brun @bloodyowl
  3. BeOp Conversational formats for advertising and editorial usage. Contextual targeting

    : cookieless
  4. Putain de Code ! A French blog and podcast talking

    about development, open to contribution and peer-reviewed
  5. None
  6. Context The ReasonReact API changed

  7. New API motivation Zero-cost bindings

  8. 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 %=> <div> {ReasonReact.string(name)} )</div> };
  9. [@react.component] let make = (~name, ~children) %=> { <div> {React.string("Hello,

    " #++ name)} )</div> }; After: Function API
  10. 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) });
  11. let make = (Props) %=> { let name = Props.name;

    let children = Props.children; return React.createElement("div", "Hello, " + name); }; After: JS
  12. 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)"; <div> <button onClick=(_event => self.send(Click))> (ReasonReact.string(message)) </button> </div>; }, };
  13. After [@react.component] let make = (~name) => { let (count,

    setCount) = React.useState(() => 0); <div> <p> {React.string(name ++ " clicked " ++ string_of_int(count) ++ " times")} </p> <button onClick={_ => setCount(_ => count + 1)}> {React.string("Click me")} </button> </div> };
  14. 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 %=> <div> {ReasonReact.string(name)} )</div> };
  15. After (JSX) React.createElement(Component, {prop1, prop2})

  16. Official solution Wrap everything → append the wrapper to every

    file
  17. Other solution Rewrite everything → leverage hooks to implement legacy

    features
  18. Previous ReasonReact API included updates: •Update(state) •UpdateWithSideEffects(state, self %=> unit)

    •SideEffects(self %=> unit) •NoUpdate
  19. React can (and will) cancel effects and updates 1. You

    set the state 2. React cancels update 3. React reapplies update
  20. How do we register side- effects so that they run

    after the state update, if they're supposed to be cancellable?
  21. Using a mutation trick

  22. type ref('value) = { mutable contents: 'value, }; ref(1) ''===

    ref(1) → false let x = ref(1); x .:= 2; x ''=== x → true
  23. let myRef = { contents: value; }; myRef.value = "bar";

    Preserves referential equality even if the value changes
  24. type fullState('action, 'state) = { state: 'state, sideEffects: ref(array(self('action, 'state)

    %=> unit)), };
  25. 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|])), } }
  26. 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|])), } }
  27. React.useEffect1( () %=> { if (Array.length(sideEffects^) > 0) { Array.forEach(sideEffects^,

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

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

    func %=> func({state, send})); sideEffects .:= ['||]; }; None; }, [|sideEffects|], );
  30. ReactUpdate useReducer

  31. None
  32. ReactCompat useRecordApi

  33. 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; };
  34. 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
  35. 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);
  36. 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(['||]);
  37. 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); };
  38. 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 .:= ['||];
  39. 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; };
  40. Now, let's talk about codemods

  41. ASTs example: translation extractor

  42. 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) },
  43. 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) },
  44. 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) },
  45. What our codemod needs to do

  46. 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
  47. Stateless Heuristic: record contains only render - Take component function

    arguments - Take `render` body from the record - Create a function with arguments and render body
  48. 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
  49. 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
  50. 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
  51. 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
  52. 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
  53. type state; type action; let make: ( ~a: string, array(ReasonReact.reactElement)

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

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

    ) %=> ReasonReact.component(state, ReasonReact.noRetainedProps, action); React.element; Interface files
  56. 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
  57. 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
  58. 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
  59. 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
  60. The overall migration

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

  62. 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).
  63. Thank you Questions?