Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Migrating a large Reason+React codebase to hooks

Migrating a large Reason+React codebase to hooks

Matthias Le Brun

March 25, 2020
Tweet

More Decks by Matthias Le Brun

Other Decks in Programming

Transcript

  1. Migrating a large
    Reason+React codebase
    to hooks

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  5. View Slide

  6. Context
    The ReasonReact
    API changed

    View Slide

  7. New API motivation
    Zero-cost bindings

    View Slide

  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 %=> {ReasonReact.string(name)} )
    };

    View Slide

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

    View Slide

  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)
    });

    View Slide

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

    View Slide

  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)";

    self.send(Click))>
    (ReasonReact.string(message))

    ;
    },
    };

    View Slide

  13. 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")}


    };

    View Slide

  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 %=> {ReasonReact.string(name)} )
    };

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  21. Using a mutation trick

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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|])),
    }
    }

    View Slide

  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|])),
    }
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  30. ReactUpdate
    useReducer

    View Slide

  31. View Slide

  32. ReactCompat
    useRecordApi

    View Slide

  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;
    };

    View Slide

  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

    View Slide

  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);

    View Slide

  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(['||]);

    View Slide

  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);
    };

    View Slide

  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 .:= ['||];

    View Slide

  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;
    };

    View Slide

  40. Now, let's talk about
    codemods

    View Slide

  41. ASTs
    example:
    translation extractor

    View Slide

  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)
    },

    View Slide

  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)
    },

    View Slide

  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)
    },

    View Slide

  45. What our codemod
    needs to do

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  60. The overall migration

    View Slide

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

    View Slide

  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).

    View Slide

  63. Thank you
    Questions?

    View Slide