ml-in-barcelona/react-rules-of-hooks-ppx

This ppx validates the rules of React hooks in reason-react's components

Raku

42

165 commits

updated Sep 3, 2026

See the code

README

react-rules-of-hooks-ppx

A ppx that validates React's Rules of Hooks at compile time.

  • Exhaustive dependencies in useEffect, useMemo, useCallback, etc.
  • Order of hooks: no conditional calls, top level only, and only inside component functions or custom hooks. Recognized component attributes: [@react.component], [@react.client.component], and [@react.async.component].

Exhaustive dependencies

[@react.component]
let make = (~randomProp) => {
  let (show, setShow) = React.useState(() => false);

  React.useEffect1(
    () => {
      /* reads randomProp, but only [|show|] is declared */
      if (randomProp) {
        setShow(prevShow => !prevShow);
      };
      None;
    },
    [|show|],
  );

  <div />;
};
15 |     [|show|],
         ^^^^^^^^
         Error (warning 22): exhaustive-deps: Missing 'randomProp' in the dependency array.
         To suppress this warning, add [@disable_exhaustive_deps] before the expression

Dependencies are tracked at member-path granularity, like eslint-plugin-react-hooks. input.page and input.limit are distinct dependencies. A declared input.page covers deeper uses such as input.page.size, but not input.limit and not the whole input; passing the whole record somewhere requires input itself. Duplicate detection, missing and unnecessary reports, and -corrections output all use the full member path.

Effects without a dependency array (React.useEffect(fn)) run after every render and can never observe stale values, so they get no exhaustiveness checking, same as eslint-plugin-react-hooks. A direct useState/useReducer setter call in such an effect warns about an infinite update chain, and unsuffixed useMemo/useCallback warn that the memoization does nothing.

Stable values are exempt from dependency arrays, with no configuration:

  • useState/useReducer setters and useRef results.
  • Same-file use* wrappers around them, transitively: let useStateValue = initial => useReducer((_, next) => next, initial); makes setters returned by useStateValue exempt in that file.
  • By naming convention, a second tuple element or record field named set[A-Z]..., set_..., or dispatch... destructured from any hook call. This one is a heuristic; plain closures (let setLocal = ...), whole-return bindings (let setAll = Hook.use()), and other names are still checked.

Order of hooks

Calling hooks inside conditionals, loops, or nested functions fails the build:

[@react.component]
let make = (~condition) => {
  if (condition) {
    let (state, _) = React.useState(() => 0); /* That's wrong */
    ();
  };
  <div />;
};
4 |     let (state, _) = React.useState(() => 0);
                         ^^^^^^^^^^^^^^^^^^^^^^^^
Error: Hooks can't be called conditionally and must be called at the top-level of your component. Move this hook call outside of conditionals, loops, or nested functions.

Install

opam install react-rules-of-hooks-ppx
(preprocess (pps reason-react react-rules-of-hooks-ppx))

Suppress locally

Attach the attribute to the hook call:

[@disable_exhaustive_deps]
React.useEffect1(() => {...}, [|dep|]);

if (condition) {
  [@disable_order_of_hooks]
  useMyHook();
};

In OCaml syntax the attribute goes after the expression: (useMyHook ())[@disable_order_of_hooks].

[@disable_order_of_hooks] remains the escape hatch for genuinely conditional hooks.

Flags

Append to the pps line:

  • -disable-exhaustive-deps turns off dependency checking.

  • -disable-order-of-hooks turns off order-of-hooks checking.

  • -corrections writes .ppx-corrected files with suggested fixes for missing dependencies; review and accept them with dune promote:

    -  React.useEffect0(() => {
    +  React.useEffect1(() => {
        Js.log(dep1);
        None;
    -  });
    +  }, [| dep1 |]);
    

Issues

Report any unexpected behaviour in the bug tracker

Acknowledgements

Thanks to @jchavarri

hooks
mlx
ppx
react
reason
reasonml

Contributors

davesnx

165 commits

ml-in-barcelona/react-rules-of-hooks-ppx

This ppx validates the rules of React hooks in reason-react's components

Raku

42

165 commits

updated Sep 3, 2026

See the code

README

react-rules-of-hooks-ppx

A ppx that validates React's Rules of Hooks at compile time.

  • Exhaustive dependencies in useEffect, useMemo, useCallback, etc.
  • Order of hooks: no conditional calls, top level only, and only inside component functions or custom hooks. Recognized component attributes: [@react.component], [@react.client.component], and [@react.async.component].

Exhaustive dependencies

[@react.component]
let make = (~randomProp) => {
  let (show, setShow) = React.useState(() => false);

  React.useEffect1(
    () => {
      /* reads randomProp, but only [|show|] is declared */
      if (randomProp) {
        setShow(prevShow => !prevShow);
      };
      None;
    },
    [|show|],
  );

  <div />;
};
15 |     [|show|],
         ^^^^^^^^
         Error (warning 22): exhaustive-deps: Missing 'randomProp' in the dependency array.
         To suppress this warning, add [@disable_exhaustive_deps] before the expression

Dependencies are tracked at member-path granularity, like eslint-plugin-react-hooks. input.page and input.limit are distinct dependencies. A declared input.page covers deeper uses such as input.page.size, but not input.limit and not the whole input; passing the whole record somewhere requires input itself. Duplicate detection, missing and unnecessary reports, and -corrections output all use the full member path.

Effects without a dependency array (React.useEffect(fn)) run after every render and can never observe stale values, so they get no exhaustiveness checking, same as eslint-plugin-react-hooks. A direct useState/useReducer setter call in such an effect warns about an infinite update chain, and unsuffixed useMemo/useCallback warn that the memoization does nothing.

Stable values are exempt from dependency arrays, with no configuration:

  • useState/useReducer setters and useRef results.
  • Same-file use* wrappers around them, transitively: let useStateValue = initial => useReducer((_, next) => next, initial); makes setters returned by useStateValue exempt in that file.
  • By naming convention, a second tuple element or record field named set[A-Z]..., set_..., or dispatch... destructured from any hook call. This one is a heuristic; plain closures (let setLocal = ...), whole-return bindings (let setAll = Hook.use()), and other names are still checked.

Order of hooks

Calling hooks inside conditionals, loops, or nested functions fails the build:

[@react.component]
let make = (~condition) => {
  if (condition) {
    let (state, _) = React.useState(() => 0); /* That's wrong */
    ();
  };
  <div />;
};
4 |     let (state, _) = React.useState(() => 0);
                         ^^^^^^^^^^^^^^^^^^^^^^^^
Error: Hooks can't be called conditionally and must be called at the top-level of your component. Move this hook call outside of conditionals, loops, or nested functions.

Install

opam install react-rules-of-hooks-ppx
(preprocess (pps reason-react react-rules-of-hooks-ppx))

Suppress locally

Attach the attribute to the hook call:

[@disable_exhaustive_deps]
React.useEffect1(() => {...}, [|dep|]);

if (condition) {
  [@disable_order_of_hooks]
  useMyHook();
};

In OCaml syntax the attribute goes after the expression: (useMyHook ())[@disable_order_of_hooks].

[@disable_order_of_hooks] remains the escape hatch for genuinely conditional hooks.

Flags

Append to the pps line:

  • -disable-exhaustive-deps turns off dependency checking.

  • -disable-order-of-hooks turns off order-of-hooks checking.

  • -corrections writes .ppx-corrected files with suggested fixes for missing dependencies; review and accept them with dune promote:

    -  React.useEffect0(() => {
    +  React.useEffect1(() => {
        Js.log(dep1);
        None;
    -  });
    +  }, [| dep1 |]);
    

Issues

Report any unexpected behaviour in the bug tracker

Acknowledgements

Thanks to @jchavarri

hooks
mlx
ppx
react
reason
reasonml

Contributors

davesnx

165 commits

Languages

Raku

63.6%

OCaml

31.4%

Perl

3.5%