Home > OS >  React: Event handlers inside or outside functional component?
React: Event handlers inside or outside functional component?

Time:08-05

Consider this simple TextField component in React:

import React, { useState } from "react";
import { TextField, Grid } from "@mui/material";

const handleEnter = (event) => {
    console.log("In handleEnter");
    if (event.key == "Enter" && event.shiftKey) {
        console.log("Detected Shift Enter key");
    } else if (event.key == "Enter") {
        console.log("Detected Enter key");
    }
};

export default function Example() {
    const [value, setValue] = React.useState("");

    const handleChatBoxChange = (event) => {
        setValue(event.target.value);
    };

    return (
        <Grid container>
            <TextField
                id='chatBox'
                maxRows={90}
                onKeyDown={handleEnter}
                value={value}
                onChange={handleChatBoxChange}
                variant='filled'
            ></TextField>
        </Grid>
    );
}

It is a very simple component, with handleEnter detecting when the user presses the Enter or Shift Enter combo. I realized that the implementation of handleEnter can be written outside of the function Example(), or inside Example(). However, handleChatBoxChange() cannot be placed outside as it is dependent on setValue which is created through useState() (and useState() must be inside a functional component. What is the best practice for this?

CodePudding user response:

Concise answer: "outside whenever practical"

Keep reading for why...


In this answer, I ignore code style and source code module management (which are opinions).

Some important concepts to understand as you read this answer are the differences between a pure function, a closure, and an "impure" function (all the functions which don't fall into the other two categories). This has already been covered extensively on Stack Overflow, so I will not reiterate here.


Besides your observation about the nature of closures, the effects on reference equality (object identity) are very different. Consider the following:

In the code below (you can imagine it's a module file named component.jsx), the callback function is created at the top level of the module, outside the component. It is initialized once when the program runs and its value never changes for the lifetime of the JS memory:

const handleClick = ev => {/*...*/};

const Component = () => {
  return <div onClick={handleClick}></div>;
};

In contrast, the code below shows the callback function being created inside the component. Because components themselves are just functions, it means that every time the component is invoked by React, the callback function is recreated (a completely new function, not equal to "the version of itself" from the previous render):

const Component = () => {
  const handleClick = ev => {/*...*/};
  return <div onClick={handleClick}></div>;
};

In a scenario like the above, where the function is used as an event handler callback directly on a child ReactElement, it won't make an observable difference. However, when passing a function to a child component or using it in a callback function to the useEffect hook, things become more complicated:

import {useEffect} from 'react';

const handleClick = ev => {/*...*/};

const Component = () => {
  useEffect(() => {
    const message = `The name of the function is ${
      handleClick.name
    }, and this message will only appear in the console when this component first mounts`;
    console.log(message);
  }, []);
  return <div onClick={handleClick}></div>;
};

In the above example, the function doesn't need to be included in the effect's dependency list because its value cannot and will never change. It wasn't created inside the component and wasn't received from props, so the reference will be stable.

Conversely, consider this example:

import {useEffect} from 'react';

const Component = () => {
  const handleClick = ev => {/*...*/};

  useEffect(() => {
    const message = `The name of the function is ${
      handleClick.name
    }, and this message will appear in the console EVERY time this component renders`;
    console.log(message);
  }, [handleClick]);
  return <div onClick={handleClick}></div>;
};

In the code above, the function is recreated every time the component is invoked during render, resulting in the effect detecting the different value in the dependency array and running the effect again. It is required to include the function in the dependency array for this very reason.

The same concept applies when passing a function to child components as props. When a component receives a function as a prop value (a prop value is just the value of a property on an object argument to the function component), it can't know anything about the reference stability of the function, so that function must always be included in dependency lists where it's used.

This leads toward the concept of memoization, and related built-in hooks (e.g. useMemo, useCallback). (Similar to types of functions, memoization is a bigger topic and it has already been covered on SO and many other places.)

I'll give a practical example of how to create stable object identity for a closure function before ending this answer:

import {useCallback, useState} from 'react';

const Component = () => {
  const [count, setCount] = useState(0);

  const adjustCount = useCallback(
    (amount) => setCount(count => count   amount),
    [setCount],
  // ^^^^^^^^
  ); // It's optional to include the "setState" function provided by `useState`,
  // in the dependency array here (it's a special case exception because React
  // already guarantees that the "setState" function it gives you will remain
  // stable). However, it doesn't hurt to include it (although you might
  // choose to omit it if you're working on a refactor toward
  // performance micro-optimizations).

  // Now, when you pass that function to a child component as a prop value,
  // the object identity won't change on subsequent renders:
  //                                      vvvvvvvvvvv
  return <SomeChildComponent adjustCount={adjustCount} />;
};

CodePudding user response:

In my opinion, "Outside" or "Inside" is depending on whether you want to be used by other components.

if handleEnter is only used by Example(), "Inside" is fine.

But for me, I will use custom hooks to encapsulate them:

function useTextInput() {
    const [value, setValue] = useState('');
    const handleChatBoxChange = (event) => {
        setValue(event.target.value);
    };

    const handleEnter = (event) => {
        console.log("In handleEnter");
        if (event.key == "Enter" && event.shiftKey) {
            console.log("Detected Shift Enter key");
        } else if (event.key == "Enter") {
            console.log("Detected Enter key");
        }
    }

    return {
      value,
      setValue,
      onChange: handleChatBoxChange,
      onEnter: handleEnter   
    }
}

export default function Example() {
    const { value, setValue, onEnter, onChange } = useTextInput();

    return (
        <Grid container>
            <TextField
                id='chatBox'
                maxRows={90}
                onKeyDown={onEnter}
                value={value}
                onChange={onChange}
                variant='filled'
            ></TextField>
        </Grid>
    );
}

update: By the way, Most of the time, we don't need to consider too much for it like what @jsejcksn said(But he was right). We could consider the problem When it happens.

  • Related