Phase 5: Autocomplete Component

Understanding the Problem

In this final phase, we'll refactor the Autocomplete component from a class component to a function component. This is the most complex component in our application and presents several new challenges:

This refactoring will require us to understand how to use useEffect with dependencies to replicate componentDidUpdate behavior, how to use useRef instead of createRef, and how to integrate all of these concepts while maintaining the component's functionality.

Experimenting with Lifecycle Methods

Before diving into the refactoring, let's examine the behavior of componentDidUpdate and componentWillUnmount in the original component to better understand what we need to replicate.

The Autocomplete component uses these lifecycle methods to manage event listeners:

Both methods include console.log statements that make it easy to observe their behavior:


componentDidUpdate() {
  if (this.state.showList) {
    document.addEventListener('click', this.handleOutsideClick);
  } else {
    console.log("Removing Autocomplete listener on update!");
    document.removeEventListener('click', this.handleOutsideClick);
  }
}

componentWillUnmount () {
  console.log("Cleaning up event listener from Autocomplete!");
  document.removeEventListener('click', this.handleOutsideClick);
}
            

By observing these console messages, we can see when each method runs:

This understanding will help us correctly implement the equivalent behavior with useEffect.

George Polya's Problem-Solving Method

Step 1: Understand the Problem

Let's examine the original Autocomplete class component to understand its functionality:


// Autocomplete.jsx
import React from 'react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';

class Autocomplete extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      inputVal: '',
      showList: false
    };
    this.inputRef = React.createRef();
  }

  componentDidUpdate() {
    if (this.state.showList) {
      document.addEventListener('click', this.handleOutsideClick);
    } else {
      console.log("Removing Autocomplete listener on update!");
      document.removeEventListener('click', this.handleOutsideClick);
    }
  }

  componentWillUnmount () {
    console.log("Cleaning up event listener from Autocomplete!");
    document.removeEventListener('click', this.handleOutsideClick);
  }

  handleInput = (e) => {
    this.setState({ inputVal: e.target.value });
  }

  selectName = e => {
    e.stopPropagation();
    this.setState({ inputVal: e.target.innerHTML, showList: false });
  }

  handleOutsideClick = () => {
    // Leave dropdown visible as long as input is focused
    if (document.activeElement === this.inputRef.current) return;
    else this.setState({ showList: false });
  }

  matches = () => {
    const { inputVal } = this.state;
    const { names } = this.props;
    const inputLength = inputVal.length;
    const matches = [];

    if (inputLength === 0) return names;

    names.forEach(name => {
      const nameSegment = name.slice(0, inputLength);
      if (nameSegment.toLowerCase() === inputVal.toLowerCase()) {
        matches.push(name);
      }
    });

    if (matches.length === 0) matches.push('No matches');

    return matches;
  }

  render() {
    const results = this.matches().map((result) => {
      const nodeRef = React.createRef();
      return (
        <CSSTransition
          nodeRef={nodeRef}
          key={result}
          classNames="result"
          timeout={{ enter: 500, exit: 300 }}
        >
          <li ref={nodeRef} className="nameLi" onClick={this.selectName}>
            {result}
          </li>
        </CSSTransition>
      )
    });

    return (
      <section className="autocomplete-section">
        <h1>Autocomplete</h1>
        <div className="auto">
          <input
            placeholder="Search..."
            ref={this.inputRef}
            onChange={this.handleInput}
            value={this.state.inputVal}
            onFocus={() => this.setState({ showList: true })}
          />
          {this.state.showList && (
            <ul className="auto-dropdown">
              <TransitionGroup>
                {results}
              </TransitionGroup>
            </ul>
          )}
        </div>
      </section>
    );
  }
}

export default Autocomplete;
            

Analyzing this code, we can see that:

Step 2: Devise a Plan

  1. Convert the class declaration to a function declaration
  2. Use props destructuring in the function parameters
  3. Replace this.state with multiple useState hooks
  4. Replace this.inputRef with useRef
  5. Use useEffect with showList as a dependency to replicate componentDidUpdate behavior
  6. Return a cleanup function from useEffect to replicate componentWillUnmount behavior
  7. Convert instance methods to regular functions
  8. Update references to this.state, this.props, and class methods
  9. Update the render logic, removing the render method and returning JSX directly

Step 3: Carry Out the Plan

1. Component Declaration, Props, and State

Let's start by converting the component declaration, setting up props and state:


// From
class Autocomplete extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      inputVal: '',
      showList: false
    };
    this.inputRef = React.createRef();
  }
  // ...
}

// To
function Autocomplete({ names }) {
  // Replace this.state with useState hooks
  const [inputVal, setInputVal] = useState('');
  const [showList, setShowList] = useState(false);
  
  // Replace this.inputRef with useRef
  const inputRef = useRef(null);
  // ...
}
            

Don't forget to import the necessary hooks:

import React, { useState, useEffect, useRef } from 'react';

2. Converting Event Handlers

Next, let's convert the event handlers to regular functions:


// From
handleInput = (e) => {
  this.setState({ inputVal: e.target.value });
}

selectName = e => {
  e.stopPropagation();
  this.setState({ inputVal: e.target.innerHTML, showList: false });
}

handleOutsideClick = () => {
  // Leave dropdown visible as long as input is focused
  if (document.activeElement === this.inputRef.current) return;
  else this.setState({ showList: false });
}

// To
const handleInput = (e) => {
  setInputVal(e.target.value);
};

const selectName = (e) => {
  e.stopPropagation();
  setInputVal(e.target.innerHTML);
  setShowList(false);
};

const handleOutsideClick = () => {
  // Leave dropdown visible as long as input is focused
  if (document.activeElement === inputRef.current) return;
  else setShowList(false);
};
            

3. Converting the Matches Method

Now let's convert the matches method:


// From
matches = () => {
  const { inputVal } = this.state;
  const { names } = this.props;
  const inputLength = inputVal.length;
  const matches = [];

  if (inputLength === 0) return names;

  names.forEach(name => {
    const nameSegment = name.slice(0, inputLength);
    if (nameSegment.toLowerCase() === inputVal.toLowerCase()) {
      matches.push(name);
    }
  });

  if (matches.length === 0) matches.push('No matches');

  return matches;
}

// To
const matches = () => {
  const inputLength = inputVal.length;
  const matchesArray = [];

  if (inputLength === 0) return names;

  names.forEach(name => {
    const nameSegment = name.slice(0, inputLength);
    if (nameSegment.toLowerCase() === inputVal.toLowerCase()) {
      matchesArray.push(name);
    }
  });

  if (matchesArray.length === 0) matchesArray.push('No matches');

  return matchesArray;
};
            

4. Converting Lifecycle Methods with useEffect

Now for the most complex part: converting componentDidUpdate and componentWillUnmount to a single useEffect hook:


// From
componentDidUpdate() {
  if (this.state.showList) {
    document.addEventListener('click', this.handleOutsideClick);
  } else {
    console.log("Removing Autocomplete listener on update!");
    document.removeEventListener('click', this.handleOutsideClick);
  }
}

componentWillUnmount () {
  console.log("Cleaning up event listener from Autocomplete!");
  document.removeEventListener('click', this.handleOutsideClick);
}

// To
useEffect(() => {
  // This effect runs after the initial render and after any render
  // where showList changes
  if (showList) {
    document.addEventListener('click', handleOutsideClick);
  }
  
  // Return a cleanup function that runs before the effect runs again
  // or when the component unmounts
  return () => {
    console.log("Cleaning up event listener from Autocomplete!");
    document.removeEventListener('click', handleOutsideClick);
  };
}, [showList]); // Dependency array includes showList
            

Notice several important differences:

5. Updating the Render Logic

Finally, let's update the render logic to use our state variables and functions directly:


// Map over matches to create result items
const results = matches().map((result) => {
  const nodeRef = React.createRef();
  return (
    <CSSTransition
      nodeRef={nodeRef}
      key={result}
      classNames="result"
      timeout={{ enter: 500, exit: 300 }}
    >
      <li ref={nodeRef} className="nameLi" onClick={selectName}>
        {result}
      </li>
    </CSSTransition>
  )
});

// Return what was previously in the render method
return (
  <section className="autocomplete-section">
    <h1>Autocomplete</h1>
    <div className="auto">
      <input
        placeholder="Search..."
        ref={inputRef}
        onChange={handleInput}
        value={inputVal}
        onFocus={() => setShowList(true)}
      />
      {showList && (
        <ul className="auto-dropdown">
          <TransitionGroup>
            {results}
          </TransitionGroup>
        </ul>
      )}
    </div>
  </section>
);
            

The main changes are:

6. Putting It All Together

Here's the complete refactored component:


// Autocomplete.jsx (Refactored)
import React, { useState, useEffect, useRef } from 'react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';

function Autocomplete({ names }) {
  // Replace this.state with useState hooks
  const [inputVal, setInputVal] = useState('');
  const [showList, setShowList] = useState(false);
  
  // Replace this.inputRef with useRef
  const inputRef = useRef(null);
  
  // Replace componentDidUpdate and componentWillUnmount with useEffect
  useEffect(() => {
    // This function runs when showList changes
    if (showList) {
      document.addEventListener('click', handleOutsideClick);
    }
    
    // Return a cleanup function that runs before the next effect
    // or when component unmounts
    return () => {
      console.log("Cleaning up event listener from Autocomplete!");
      document.removeEventListener('click', handleOutsideClick);
    };
  }, [showList]); // Run this effect when showList changes
  
  // Convert class methods to regular functions
  const handleInput = (e) => {
    setInputVal(e.target.value);
  };
  
  const selectName = (e) => {
    e.stopPropagation();
    setInputVal(e.target.innerHTML);
    setShowList(false);
  };
  
  const handleOutsideClick = () => {
    // Leave dropdown visible as long as input is focused
    if (document.activeElement === inputRef.current) return;
    else setShowList(false);
  };
  
  const matches = () => {
    const inputLength = inputVal.length;
    const matchesArray = [];

    if (inputLength === 0) return names;

    names.forEach(name => {
      const nameSegment = name.slice(0, inputLength);
      if (nameSegment.toLowerCase() === inputVal.toLowerCase()) {
        matchesArray.push(name);
      }
    });

    if (matchesArray.length === 0) matchesArray.push('No matches');

    return matchesArray;
  };
  
  // Map over matches to create result items
  const results = matches().map((result) => {
    const nodeRef = React.createRef();
    return (
      <CSSTransition
        nodeRef={nodeRef}
        key={result}
        classNames="result"
        timeout={{ enter: 500, exit: 300 }}
      >
        <li ref={nodeRef} className="nameLi" onClick={selectName}>
          {result}
        </li>
      </CSSTransition>
    )
  });

  // Return what was previously in the render method
  return (
    <section className="autocomplete-section">
      <h1>Autocomplete</h1>
      <div className="auto">
        <input
          placeholder="Search..."
          ref={inputRef}
          onChange={handleInput}
          value={inputVal}
          onFocus={() => setShowList(true)}
        />
        {showList && (
          <ul className="auto-dropdown">
            <TransitionGroup>
              {results}
            </TransitionGroup>
          </ul>
        )}
      </div>
    </section>
  );
}

export default Autocomplete;
            

Step 4: Look Back and Review

Let's verify that our refactored component maintains the same functionality as the original:

Let's run the tests to confirm that everything works as expected:

npm test

The tests for the Autocomplete component should pass, confirming that our refactoring was successful.

Understanding useRef

What is useRef?

The useRef hook is a function that returns a mutable ref object whose .current property is initialized to the passed argument. The returned object will persist for the full lifetime of the component.

const refContainer = useRef(initialValue);

There are two main uses for useRef:

  1. Accessing DOM elements: When you need to interact with a DOM element directly, you can use a ref to reference it
  2. Storing mutable values: When you need to keep track of a value that shouldn't trigger a re-render when it changes

useRef vs createRef

In React, there are two ways to create refs: useRef and createRef. The key difference is that useRef returns the same ref object on every render, while createRef creates a new ref on every render.

createRef useRef
Returns a new ref object on every render Returns the same ref object on every render
Used in class components Used in function components
Created once in constructor or as instance property Created with the useRef hook

In our Autocomplete component, we used useRef for the input element because we need the same ref to persist across renders. However, we continued to use createRef inside the map function because each list item needs its own ref, and these refs are created dynamically.

Using useRef for DOM Elements

To use useRef to access a DOM element, you assign the ref to the ref attribute of a JSX element:

function TextInputWithFocusButton() {
  const inputRef = useRef(null);
  
  const focusInput = () => {
    // Accessing the DOM element via .current
    inputRef.current.focus();
  };
  
  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus the input</button>
    </div>
  );
}

In this example, inputRef.current points to the actual DOM element, allowing us to call methods like focus() on it.

Using useRef for Mutable Values

useRef can also be used to hold any mutable value. Unlike state, updating a ref's .current property doesn't cause a re-render:

function Timer() {
  const [count, setCount] = useState(0);
  const intervalIdRef = useRef(null);
  
  const startTimer = () => {
    if (intervalIdRef.current) return;
    intervalIdRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };
  
  const stopTimer = () => {
    clearInterval(intervalIdRef.current);
    intervalIdRef.current = null;
  };
  
  // Clean up on unmount
  useEffect(() => {
    return () => {
      if (intervalIdRef.current) {
        clearInterval(intervalIdRef.current);
      }
    };
  }, []);
  
  return (
    <div>
      Count: {count}
      <button onClick={startTimer}>Start</button>
      <button onClick={stopTimer}>Stop</button>
    </div>
  );
}

In this example, intervalIdRef is used to store the interval ID without triggering re-renders when it changes.

useEffect with Dependencies

Understanding the Dependency Array

In our refactored Autocomplete component, we used useEffect with [showList] as the dependency array. This tells React to run the effect after the initial render and after any render where showList has changed.

The dependency array is a powerful feature of useEffect that allows you to control when the effect runs:

The Cleanup Function

When useEffect runs again due to a dependency change, React first runs the cleanup function from the previous effect run, then runs the new effect. This ensures that resources are properly managed and prevents memory leaks.

In our component, this means:

  1. When showList changes from false to true:
    • The cleanup function runs (removing the event listener, although none was added yet)
    • The effect runs, adding the event listener
  2. When showList changes from true to false:
    • The cleanup function runs (removing the event listener)
    • The effect runs, but doesn't add an event listener since showList is now false
  3. When the component unmounts:
    • The cleanup function runs (removing any event listener that was added)

Dependency Array Gotchas

When working with dependency arrays, be aware of these common issues:

Missing Dependencies

If you use a variable inside your effect but don't include it in the dependency array, you risk working with stale values. React's linter will usually warn you about this:

function Counter() {
  const [count, setCount] = useState(0);
  
  // ⚠️ Missing dependency: count
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);
      // This will always log the initial value of count (0)
      // because the effect closure captures the initial value
      setCount(count + 1);
    }, 1000);
    
    return () => clearInterval(id);
  }, []); // Missing count in dependency array
  
  return <div>Count: {count}</div>;
}

Function Dependencies

When using functions inside useEffect, you need to include them in the dependency array if they use any state or props:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  // This function depends on the query prop
  const fetchResults = async () => {
    const response = await fetch(`https://api.example.com/search?q=${query}`);
    const data = await response.json();
    setResults(data);
  };
  
  // ⚠️ fetchResults should be included in the dependency array
  useEffect(() => {
    fetchResults();
  }, [query]); // Should include fetchResults
  
  // ...
}

To solve this issue, you can either:

  1. Move the function inside the effect
  2. Use the useCallback hook to memoize the function

Differences from Lifecycle Methods

Our refactoring reveals important differences between how class component lifecycle methods and function component hooks work:

Class Lifecycle Methods Function Component with useEffect
componentDidMount runs once after the initial render useEffect(() => {}, []) runs once after the initial render
componentDidUpdate runs after every render except the first useEffect(() => {}, [deps]) runs after the initial render and whenever dependencies change
componentWillUnmount runs once before unmount Cleanup function returned from useEffect runs before unmount and before every re-run of the effect
Need explicit conditions to control when code runs Control when code runs with dependency arrays
Setup and cleanup in separate methods Setup and cleanup together in one effect

In our Autocomplete component, we can see these differences clearly:

Bonus: TransitionItem Component

The createRef Problem

In our refactored Autocomplete component, we're still using React.createRef() inside the map function:

const results = matches().map((result) => {
  const nodeRef = React.createRef();
  return (
    <CSSTransition
      nodeRef={nodeRef}
      key={result}
      classNames="result"
      timeout={{ enter: 500, exit: 300 }}
    >
      <li ref={nodeRef} className="nameLi" onClick={selectName}>
        {result}
      </li>
    </CSSTransition>
  )
});

This is problematic because createRef creates a new ref object on every render, which means that the refs will be different each time the component renders. While this may work in simple cases, it can lead to subtle bugs and performance issues.

The README bonus phase suggests creating a separate TransitionItem component that uses useRef instead of createRef. Let's implement this improvement:

Creating a TransitionItem Component

We'll create a separate component for each list item that uses useRef at the top level:

// Create a separate component for each list item
function TransitionItem({ result, onClick, ...props }) {
  // Use useRef at the top level of the component
  const nodeRef = useRef(null);
  
  return (
    <CSSTransition
      nodeRef={nodeRef}
      classNames="result"
      timeout={{ enter: 500, exit: 300 }}
      {...props} // Pass through any props from TransitionGroup
    >
      <li ref={nodeRef} className="nameLi" onClick={onClick}>
        {result}
      </li>
    </CSSTransition>
  );
}

This component:

Using the TransitionItem Component

Now we can update our results mapping to use the new component:

// Use our TransitionItem component instead of inline CSSTransition
const results = matches().map((result) => (
  <TransitionItem
    key={result}
    result={result}
    onClick={selectName}
  />
));

This approach is much cleaner and more efficient:

Complete Refactored Component with TransitionItem

Here's the complete refactored Autocomplete component with the TransitionItem improvement:


// Autocomplete.jsx with TransitionItem
import React, { useState, useEffect, useRef } from 'react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';

// Create a separate component for each list item
function TransitionItem({ result, onClick, ...props }) {
  // Use useRef at the top level of the component
  const nodeRef = useRef(null);
  
  return (
    <CSSTransition
      nodeRef={nodeRef}
      classNames="result"
      timeout={{ enter: 500, exit: 300 }}
      {...props} // Pass through any props from TransitionGroup
    >
      <li ref={nodeRef} className="nameLi" onClick={onClick}>
        {result}
      </li>
    </CSSTransition>
  );
}

function Autocomplete({ names }) {
  const [inputVal, setInputVal] = useState('');
  const [showList, setShowList] = useState(false);
  const inputRef = useRef(null);
  
  useEffect(() => {
    if (showList) {
      document.addEventListener('click', handleOutsideClick);
    }
    
    return () => {
      console.log("Cleaning up event listener from Autocomplete!");
      document.removeEventListener('click', handleOutsideClick);
    };
  }, [showList]);
  
  const handleInput = (e) => {
    setInputVal(e.target.value);
  };
  
  const selectName = (e) => {
    e.stopPropagation();
    setInputVal(e.target.innerHTML);
    setShowList(false);
  };
  
  const handleOutsideClick = () => {
    if (document.activeElement === inputRef.current) return;
    else setShowList(false);
  };
  
  const matches = () => {
    const inputLength = inputVal.length;
    const matchesArray = [];

    if (inputLength === 0) return names;

    names.forEach(name => {
      const nameSegment = name.slice(0, inputLength);
      if (nameSegment.toLowerCase() === inputVal.toLowerCase()) {
        matchesArray.push(name);
      }
    });

    if (matchesArray.length === 0) matchesArray.push('No matches');

    return matchesArray;
  };
  
  // Use our TransitionItem component instead of inline CSSTransition
  const results = matches().map((result) => (
    <TransitionItem
      key={result}
      result={result}
      onClick={selectName}
    />
  ));

  return (
    <section className="autocomplete-section">
      <h1>Autocomplete</h1>
      <div className="auto">
        <input
          placeholder="Search..."
          ref={inputRef}
          onChange={handleInput}
          value={inputVal}
          onFocus={() => setShowList(true)}
        />
        {showList && (
          <ul className="auto-dropdown">
            <TransitionGroup>
              {results}
            </TransitionGroup>
          </ul>
        )}
      </div>
    </section>
  );
}

export default Autocomplete;
            

Key Insights from This Refactoring

Understanding the Mental Model

One of the most important insights from refactoring the Autocomplete component is how the mental model for function components differs from class components:

In the function component world:

Benefits of Function Components

This refactoring demonstrates several benefits of function components with hooks:

React Hooks Best Practices

From this refactoring, we can extract several best practices for working with React hooks:

  1. Split related state: Use multiple useState calls for unrelated state
  2. Be specific with dependencies: Include all values used in the effect in the dependency array
  3. Clean up resources: Always return a cleanup function when setting up resources that need to be released
  4. Prefer useRef over createRef in function components: useRef persists across renders
  5. Create smaller, focused components: Extract reusable pieces like our TransitionItem
  6. Separate concerns: Use multiple useEffect hooks for unrelated functionality
  7. Think about synchronization: useEffect is about synchronizing with external systems or state

Real-World Applications

When to Use This Pattern

The patterns we've seen in this Autocomplete component are common in many real-world applications:

Extensions and Improvements

In a real-world application, you might extend this component in several ways:

Conclusion

Congratulations on completing the final phase of the refactoring project! You've successfully converted all the class components in the Widgets app to function components using React Hooks.

Let's recap what you've learned throughout this process:

You've gained a solid understanding of React Hooks and how they compare to lifecycle methods in class components. You've also learned important patterns and best practices for working with function components.

This knowledge will serve you well as you continue your journey with React. Function components with hooks are now the recommended approach for building React applications, and the patterns you've learned here are widely used in the React ecosystem.

Keep exploring and building with these tools!