Skip to content

Instantly share code, notes, and snippets.

@HerringtonDarkholme
Last active May 24, 2024 21:42
Show Gist options
  • Save HerringtonDarkholme/1e3781c59ccfbedea69e2864bf468c2a to your computer and use it in GitHub Desktop.
Save HerringtonDarkholme/1e3781c59ccfbedea69e2864bf468c2a to your computer and use it in GitHub Desktop.
React Effect Syntax Extension

React Effect Syntax Extension

This syntax extension is a mere fantasy of future React Script syntax. It is not a RFC, proposal or suggestion to React team or community. It is just a fun idea to imagine how React could be in the future.

Read this like a fan-fiction, for fun. :)

Background

There are two recent new features in React community that are introducing new extensions to JavaScript, both syntax and semantics:

  • Flow introduces two new keywords component and hook to differentiate regular functions from React components and hooks.

  • React Compiler introduces new auto-memoization semantics to React components, which allows React to automatically memoize components without using useCallback or useMemo.

The compiler now can automatically detect the dependencies and mutations inside components and memoize them automatically. This is a huge improvement in DX for React developers so we don't need to annotate useMemo/useCallback dependencies manually. Can we extend this idea further to useEffect? So that we don't need to add manual deps array.

Can we make it even better by introducing a new syntax to make useEffect smarter?

The synergy between Syntax and Compiler naturally gives rise to the idea of a new syntax useEffect.

effect keyword

What if we can have a new keyword in Flow syntax: effect? This keyword is a new syntax sugar for useEffect, but it can automatically detect the dependencies of the effect callback function.

For example:

component Map({ zoomLevel }) {
  const containerRef = useRef(null)
  const mapRef = useRef(null)

  effect { 
    if (mapRef.current === null) {
      mapRef.current = new MapWidget(containerRef.current);
    }
    const map = mapRef.current;
    map.setZoom(zoomLevel);
  } // no deps array! zoomLevel is automatically detected as a dependency

  // ....
}

is equivalent to

export default function Map({ zoomLevel }) {
  const containerRef = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    if (mapRef.current === null) {
      mapRef.current = new MapWidget(containerRef.current);
    }

    const map = mapRef.current;
    map.setZoom(zoomLevel);
  }, [zoomLevel]); // zoomLevel automatically added here
  // ....
}

effect accepts a block statement { ... } as its body, and it will be translated to useEffect with automatically detected dependency array.

There are several principles behind this new syntax:

  1. effect is a syntax sugar for useEffect, the semantics are the same.
  2. effect is a DX improvement since users will not need annotate the dep array most of the time.
  3. users can optionally annotate the dep array manually if they want to handle edge cases
  4. effect syntax can be further extended to support more features in the future, such as async effects

effect with cleanup

effect can also have a cleanup block inside it, which is equivalent to useEffect with cleanup function:

hook useChatRoom(roomId) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');
  effect {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    cleanup {
      connection.disconnect();
    }
  }
}

is equivalent to

function useChatRoome(roomId) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);
}

cleanup's design:

  • cleanup appears only immediately inside effect block. It cannot appear outside of effect block or nested inside other blocks like if or for.

  • It will only run when the effect is cleaned up.

  • It can also capture the variables from the effect scope and outer component scope, such as serverUrl and roomId in this example.

  • cleanup does not necessarily need to be at the end of the effect block. It can appear anywhere inside the effect block.

cleanup is like defer in Go, but we will see why it is useful in async effect.

manually annotate deps array

effect can also optionally accept a deps array, just like useEffect:

const [serverUrl, setServerUrl] = useState('https://localhost:1234');
effect([serverUrl, roomId]) {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();
}

This is useful when you want to skip some dependencies from auto-detection for edge cases. Currently intentionally skipping dependency requires eslint-disable comments. The explicit effect syntax can convey the intention more clearly without needing to disable linter.

Here are some common use cases:

effect { ... } // auto detect deps
effect([]) { ... } // run only on mounting
effect() { ... } // run every time when compontn re-renders
effect([serverUrl]) { ... } // run only when serverUrl changes

Extended goal: async effect

useEffect does not support async function as argument because the return type must be a cleanup function instead of a promise. Using async function inside useEffect will not work as expected because React cannot call the cleanup function before the promise is resolved.

effect can be extended to support async function as its body, since cleanup can be compiled out of the async function and returned earlier.

component Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);
  async effect {
    let ignore = false;
    cleanup { ignore = true; }
    setBio(null);
    const result = await fetchBio(person);
    if (!ignore) {
      setBio(result);
    }
  }
  // ...
}

The code is equivalent to:

function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);
  useEffect(() => {
    // statement before first await is executed immediately
    let ignore = false;
    setBio(null);
    // statements after await are wrapped in async IIFE
    (async function () {
      const result = await fetchBio(person);
      if (!ignore) {
        setBio(result);
      }
    })();
    // return cleanup function
    return () => { ignore = true; }
  }, [person]);
}

The rule of async effect:

  • statements before first await are executed immediately, just like regular useEffect
  • statements after await are wrapped in async IIFE
  • cleanup can capture variables before await, but not after await since they are in a different scope. Compiler can emit an error if this rule is violated.

This can be roughly inspired by Vue's watchEffect which supports async function.

Conclusion

I hope you enjoyed this fantasy and it can be a fun idea to think about the ergonomics of React in the future!

@AsJoy
Copy link

AsJoy commented May 23, 2024

hah , the case really make sense for b-side front-end developer to limit the way how to write similar code

@chengluyu
Copy link

This proposal is fantastic! I have long felt that JavaScript syntax has limited React's development, it's about time people did some JavaScript extensions for React.

I once imagined syntactic sugar for useState and useRef. We can also propose similar syntactic sugar for useState and useRef.

component Page() {
  state person = 'Alice';
  state bio = null;
  async effect {
    let ignore = false;
    cleanup { ignore = true; }
    mutate bio = null;
    const result = await fetchBio(person);
    if (!ignore) {
      mutate bio = result;
    }
  }
  // ...
}

We use state <identifier> = <expression> to declare state. The results of useState are often destructed and bound to two identifiers, which makes the code very long. This syntactic sugar can reduce the length of lines. For example, the following is a number state. We can view it as a variation of const/let/var declarations.

state count = 0;
state userId: string | null = null; // Works with type annotations.

When we need to update the state, we use the normal assignment operators, but prefix it with the keyword mutate to explicitly indicate that this operation is different from a regular assignment as it triggers a re-render and it doesn't change the value in current context.

mutate count = 42; // Set it to a new value.
mutate count += 1; // Compound assignment operators also work.

The useState function accepts an update function as the parameter. Based on this, we can expand the mutate syntax.

state stack: number[] = []

<Button onClick={() => {
  mutate stack => [...stack, 0]
}}>
  Append
</Button>

The beauty here is that we've integrated mutate and arrow function, making it read as if it's "transforming the stack into the expression on the right."

Note that mutate is not a statement but an expression. So, you can get the expression on the RHS, as shown below.

state x = 0;
console.log(mutate x = 42); // logs 42

And the biggest caveat should be that mutate does not change the state's value in this render.

state x = 0;
effect {
  mutate x = 42;
  console.log(x); // logs 0
}

@chengluyu
Copy link

For people who are interested, I made an editor demo which implements the syntax: https://javascript-for-react.vercel.app/
image

@pieterv
Copy link

pieterv commented May 24, 2024

Effect syntax would be cool! Your syntax looks pretty spot on.

For the useState syntax, i like the idea but the thing i have a hard time with is if you created a custom hook which creates a state things get awkward. e.g.

hook useNumIncrement(initialNum: number) {
  state num = initialNum;
  return [
    num,
    (increaseBy: number) => mutate num => num + increaseBy
  ];
}

component Foo() {
  state numAbs = 0;
  const [numInc, increaseNum] = useNumCount(0);

  const setNumAbs = () => mutate 10;
  const setNumInc = () => increaseNum(10); // eww
}

I think ideally we would also allow custom hooks to expose new "state" like you can do today by just matching the useState return API. Any thoughts on how that syntax could look?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment