You have successfully passed the first round of interviews, and now you are facing a coding challenge. The interviewer decides to switch things up and presents a novel challenge: Can you write a useEffect from scratch?

To make the task clearer, the interviewer provides some guidelines:

  • The implementation should cover the most common use cases.
  • The time limit is one hour. Cover as much as you can.
  • Focus on dependency handling and not on the execution timing.
  • It is preferred to write the implementation in TypeScript.

Now that you know what is expected, it's up to you where and how to start. I highly recommend that you try to solve the problem on your own before reading the rest of the article.

None
Photo by Mohammad Rahmani on Unsplash

Some might be tempted to start writing code immediately, but I would recommend that you take a step back and think about the problem. Ideally, you could use a whiteboard or Excalidraw to sketch out the solution while talking it out with the interviewer.

None
useSyncEffect Requirements

This is a great opportunity to show how you think through a problem and how you would approach it. Even if you don't finish the implementation, the interviewer will know that you are capable of finishing the problem if given the appropriate time. Sketch a simple execution flow diagram to demonstrate your understanding of the component lifecycle. Keep this high-level so as not to take up too much time:

None
High-level flowchart

Understanding the Requirements

Before we code, let's break down what useEffect fundamentally does:

  1. Run After Render: It needs to execute some code (the "effect") after* the component has rendered.
  2. Conditional Execution: The effect should be rerun only if certain inputs/values have changed since the last render. • No dependencies (undefined): Runs every time. • Empty dependency array ([ ]): Run only once after the initial mount • Dependencies provided ([dep1, dep2]): Run on mount and when any dependency changes.
  3. Cleanup: It needs to handle a "cleanup" function returned by the effect, running it before the next effect execution.
  • The keen-eyed among you might have noticed that the first requirement is very hard to meet. The effect we will write will run synchronously and not after the component has rendered. This is intentional, and we will discuss the implications at the end.

Implementing the hook

Now that we have a plan, let's start implementing the hook step by step. First, we need the basic hook structure. We also need a way to know if it's the first time the hook is running. As such, we need a way to persist information across rerenders, and React's useRef is perfect for this use case.

import { useRef } from 'react'

type Effect = () => void // Simplified for now

function useSyncEffect(effect: Effect, deps?: unknown[]) {
  const isFirstRender = useRef(true)

  // For now, only run on the first render
  if (isFirstRender.current) {
    console.log('Effect runs on first render')
    effect()
    isFirstRender.current = false // Mark first render as done
  }
}

Step 2: Storing Previous Dependencies

To check if dependencies have changed, we need to remember what they were on the previous render. Once again, useRef comes to the rescue.

import { useRef } from 'react'

type Effect = () => void

function useSyncEffect(effect: Effect, deps?: unknown[]) {
  const isFirstRender = useRef(true)
  const prevDeps = useRef(deps) // Store previous dependencies

  if (isFirstRender.current) {
    console.log('Effect runs on first render')
    effect()
    isFirstRender.current = false
    // Store initial deps after first run
    prevDeps.current = deps
  }
}

Step 3: Comparing Dependencies

Now, we need a way to compare the previous dependencies (prevDeps.current) with the current dependencies. We'll create a helper function for this called areDepsEqual. React uses Object.is for comparison, so we'll do the same.

import { useRef } from 'react'
type Effect = () => void

// Helper function to compare dependency arrays
function areDepsEqual(oldDeps?: unknown[], newDeps?: unknown[]): boolean {
  // If either is undefined, they're not equal
  if (oldDeps === undefined || newDeps === undefined) return false
  // Different array lengths mean dependencies changed
  if (oldDeps.length !== newDeps.length) return false
  // Check each dependency for changes
  return newDeps.every((dep, i) => Object.is(dep, oldDeps[i]))
}

function useSyncEffect(effect: Effect, deps?: unknown[]) {
  const isFirstRender = useRef(true)
  const prevDeps = useRef(deps)

  if (isFirstRender.current) {
    effect()
    isFirstRender.current = false
    prevDeps.current = deps
  } else {
    // Compare deps on subsequent renders
    const depsChanged = !areDepsEqual(prevDeps.current, deps)
    console.log('Subsequent render, deps changed:', depsChanged)

    if (depsChanged) {
      // Run effect if deps changed
      console.log('Running effect on dependency change')
      effect()
      // Store the new dependencies after running the effect
      prevDeps.current = deps
    }
  }
}

Step 4: Conditional Execution

Let's refine our implementation to handle different dependency scenarios: We need to handle undefined (run on every render), the empty array (run only once), and specific dependencies (run when they change).

import { useRef } from 'react'
type Effect = () => void

// areDepsEqual function from Step 3, has been excluded for brevity

function useSyncEffect(effect: Effect, deps?: unknown[]) {
  const isFirstRender = useRef(true)
  const prevDeps = useRef(deps)

  if (isFirstRender.current) {
    effect()
    isFirstRender.current = false
    prevDeps.current = deps 
    return
  }

  // Dependency check logic:
  // - If deps is undefined, run every time (like React's useEffect)
  // - Otherwise, only run if dependencies changed
  const shouldRun = deps === undefined || !areDepsEqual(prevDeps.current, deps);

  if (shouldRun) {
    console.log('Running effect due to dependency change')
    effect()
    // Update prevDeps after running the effect
    prevDeps.current = deps
  }
}

We have refined our hook with clearer dependency handling. First, we check if it's the first render and run the effect if it is. For subsequent renders, the hook checks if deps are undefined or if they have changed compared to the previous render. This logic addresses the following scenarios:

  • If deps is undefined, useSyncEffect(() => {}), the effect runs on every render.
  • If deps is an empty array, useSyncEffect(() => {}, []), the effect only runs once. If deps has values, useSyncEffect(() => {}, [dep1, dep2]), the effect runs when any dependency changes.

The empty array case is handled by areDepsEqual, which identifies that two empty arrays are equal, thus preventing unnecessary reruns.

Step 5: Adding the Cleanup Mechanism

The final piece of the puzzle is the cleanup function. The effect function might return another function (the cleanup). We need to store this cleanup function and run it before the next time the effect runs.

import { useRef } from 'react'

// Define types for Effect and Cleanup
type Cleanup = () => void
type Effect = () => Cleanup | void // Can return a Cleanup function or nothing

// Helper function to compare dependency arrays
function areDepsEqual(oldDeps?: unknown[], newDeps?: unknown[]): boolean {
  if (oldDeps === undefined || newDeps === undefined) return false
  if (oldDeps.length !== newDeps.length) return false
  return newDeps.every((dep, i) => Object.is(dep, oldDeps[i]))
}

function useSyncEffect(effect: Effect, deps?: unknown[]) {
  const isFirstRender = useRef(true)
  const prevDeps = useRef(deps)
  const cleanupFn = useRef<Cleanup | null>(null) // Store the cleanup function

  // Helper to run effect and store its cleanup
  const runEffect = () => {
    // Run previous cleanup if it exists
    if (cleanupFn.current) {
      cleanupFn.current()
      cleanupFn.current = null
    }

    try {
      // Run the effect and capture cleanup
      const result = effect()
      if (typeof result === 'function') {
        cleanupFn.current = result
      }
    } catch (error) {
      console.error('Error in effect:', error)
    }
  }

  // Always run on first render
  if (isFirstRender.current) {
    runEffect()
    isFirstRender.current = false
    prevDeps.current = deps
    return
  }


  const shouldRun = deps === undefined || !areDepsEqual(prevDeps.current, deps)

  if (shouldRun) {
    runEffect()
    prevDeps.current = deps
  }
}

export { useSyncEffect }

Once again, useRef is ideal for storing our cleanup function. For this, we also updated the Effect type to show it can return a cleanup function or void. Next, we introduced a helper function, runEffect, which handles the following:

  • Runs the previous cleanup function if it exists
  • Executes the new effect inside a try/catch block
  • Captures any returned cleanup function for future use

This helper function is then called on both the first render and when a dependency changes. This completes our implementation of useSyncEffect.

The big difference

Our useSyncEffect gets the job done for basic dependency checks and cleanup. However, it has one big difference from the real useEffect. It runs synchronously, as it executes during the component render cycle.

React's actual useEffect, on the other hand, runs asynchronously after the render is committed to the screen and the browser has painted. What we wrote is exactly what React tries to avoid, as long-running effects shouldn't block the main thread and make the UI unresponsive.

In the end, our implementation is more like useLayoutEffect in terms of timing. More specifically, it has synchronous execution after DOM mutations and before the browser paints.

I should also point out that building a real useEffect is basically impossible as you would need to have access to the internal API of React.

In the Context of an Interview

Understanding this sync vs async difference is a big plus. While building useSyncEffect demonstrates your grasp of hooks, dependency arrays, and cleanup logic, acknowledging its limitations is just as important. Despite the key simplification (synchronous execution), this challenge will test your knowledge of core React mechanics, your ability to manage component lifecycle concepts, and your awareness of performance considerations in React (like non-blocking the UI).

Going Deeper

For those who want more, diving into official documentation and related articles can solidify your grasp of React's effect:

If you have any questions or feedback, please let me know! I look forward to reading your thoughts.

Also, you can find me at marinodrazic.com