The best way I have found to really have an accurate mental model of the programming abstractions I use whether compilers, promises or frameworks like react, it is to crack open the blackbox and understand the essential implementation details.
While there are a number of excellent posts on how hooks work under the hood, the inner workings of useEffect and how it relates to the lifecycle of a component continue to be a source of puzzlement for many.
As I’ll attempt to show when you peak behind the curtain the useEffect hook’s implementation is quite straightforward and fits elegantly into React’s reconciliation algorithm.
By the end I hope we’ll be able to confidently answer questions such as:

  • Why do we have to call useEffect hooks in the same order?
  • How are hooks represented by a fiber?
  • When and how exactly are values in the dependency array compared?
  • When and how are effects cleaned up?
  • Why can’t we take advantage of React fibers in my useEffect callbacks?

First, let’s briefly recap how React fibers and the reconciliation algorithm work; during reconciliation React builds up a work-in-progress fiber tree and computes a set of changes by walking the component tree and recursively calling render. Each React element is thus turned into a fiber node of  corresponding type  that keeps record of the work to be done. Think of a fiber as representing a unit of work that can be independently scheduled, paused and aborted.
When an update is called, React will add the update to a component update queue, for instance, when setState is called on render, React calls the updater function which was passed into setState. After the updater is finished the fiber gets a tag that a change needs to be made in the DOM.
The list of changes are then propagated up to the parent fiber and merged into its list of changes. This list of changes is also called effect list. When React reaches the root node the work in progress tree is marked as a pending commit.

Those changes, however, are not immediately committed to a rendering target such as the DOM.  That happens in the commit phase, this phase is atomic and cannot be interrupted, otherwise there might be UI inconsistencies.
During the commit phase React iterates over the effect list and makes its changes to the rendering target (e.g. DOM).

Let’s look at some code:
useEffect is defined in ReactHooks.js and its type signature clues us in to how it works; it accepts as first argument a function creating the effect, which optionally returns a function (cleaning up the effect) and as second argument an optional array of inputs (the dependency array) of variable type.
We see that the functions first resolves a dispatcher and then delegates to it.

//react/blob/master/packages/react/src/ReactHooks.js#L104
export function useEffect(
  create: () => (() => void) | void,
  inputs: Array<mixed> | void | null,
) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, inputs);
}

The hook dispatchers are resolved depending on the current context, if it's the initial render and the component just mounted HooksDispatcherOnMount and otherwise HooksDispatcherOnUpdate is returned, correspondingly the dispatcher returns either mountEffect or updateEffect.

//react/blob/master/packages/react-reconciler/src/ReactFiberHooks.old.js#L570
const HooksDispatcherOnMount: Dispatcher = {
	...
  useEffect: mountEffect,
  ...
};

const HooksDispatcherOnUpdate: Dispatcher = {
  ...
  useEffect: updateEffect,
  ...
}

Without looking at the implementation, from our experience working with useEffect we know that these cases differ in at least one respect; the create function of useEffect is always invoked on mount, regardless of its second argument.

Let us first look at the more common update case; updateEffect delegates to updateEffectImpl  to pass in the current fiber and hook effect tags. I don’t want to go too much into effect tags here, suffice it to mention that each fiber’s effects are encoded in an effectTag, they define  the  work  that needs to be done for instances after updates have been processed, similarly there are hook effect tags carrying information about the hook effect's context, e.g. whether the component is unmounting or whether the effect should be invoked at all (the NoHookEffect tag).
updateEffectImpl first calls updateWorkInProgressHook to get a new hook instance, which is basically just a clone of the current hook or if we are in a work-in-progress tree the current work-in-progress hook:

const newHook: Hook = {
  memoizedState: currentHook.memoizedState,

  baseState: currentHook.baseState,
  queue: currentHook.queue,
  baseUpdate: currentHook.baseUpdate,

  next: null,
};

When a hook is called in our component it builds up a queue where hooks are represented as linked list in their call order with each hook’s next field pointing to the next hook. Since these are copied over from each render, we see why we cannot call hooks conditionally or change their call order from render to render.
The baseState and baseUpdate fields are relevant to useState and useDispatch hooks,  useEffect most importantly uses memoizedState to hold a reference to the previous effect. Let’s look at why.

//react/packages/react-reconciler/src/ReactFiberHooks.old.js#L1218
function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        pushEffect(NoHookEffect, create, destroy, nextDeps);
        return;
      }
    }
  }

  sideEffectTag |= fiberEffectTag;
  hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}

The most interesting thing happening here is that if there is a currentHook we fetch the previous effect from the hook’s memoizedState field to get the previous dependencies and compare them to the next dependencies. If they are equal, we push an effect onto the queue with the NoHookEffect tag and return, which means that the effect will still be run during commit, but it won’t be executed (its create function won't be invoked). Finally, if the dependencies are not equal, we push the effect onto the queue with an effect tag that ensures the effect will fire.
As a side note areHookInputsEqual delegates to Object.is instead of a plain object reference comparison to catch javascript quirks such as NaN === NaN // false.

We skip over the source ofmountEffectImpl here, since it only differs from updateEffectImpl in that it does not check the dependency array and simply pushes the hook on the effect queue to be executed.

That is basically all that happens during reconciliation; values from previous useEffect hooks are cloned, the new dependencies compared to previous ones which were saved on the memoizedState field to determine whether the effect should fire or not and that information is pushed on the effect queue.

The next time we see our effect is after React has finished reconciliation, every render has been called and the list of updates to be committed to the rendering target aggregated. We are in the commit phase now and commitWork calls commitHookEffectList:

function commitWork(current: Fiber | null, finishedWork: Fiber): void {
	...
  commitHookEffectList(UnmountMutation, MountMutation, finishedWork);
	...
}

commitHookEffectList in turn iterates over the effect list, checks the tag to determine in which phase the effect has been added to the list and fires create or destroy respectively.
We see that in the case of an unmountTag  the destroy clean up function is called. In case, we are in an update phase, create is called firing the effect and the destroy function returned from `createz is simply saved on the effect for future reference in the unmount phase. If the effect has been tagged with NoHookEffect it is simply skipped.

// react/ReactFiberCommitWork.old.js at master · facebook/react
function commitHookEffectList(
  unmountTag: number,
  mountTag: number,
  finishedWork: Fiber,
) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & unmountTag) !== NoHookEffect) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          destroy();
        }
      }
      if ((effect.tag & mountTag) !== NoHookEffect) {
        // Mount
        const create = effect.create;
        effect.destroy = create();

        if (__DEV__) {...}
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

Now we also see why the code we run in useEffect cannot take advantage of fibers which are able to pause in order to let other higher priority work finish before rendering is resumed. This is because the effect is executed inside of commitWork which makes atomic changes to the rendering target to avoid UI inconsistencies. This is important to bear in mind lest one is tempted to perform computationally intensive, synchronous work inside a useEffect hook.

I hope this basic understanding of how useEffect works under the hood helps you become more confident working with useEffect and avoid common pitfalls. It may also have encouraged you to pull away the curtain once in a while and take a look at the React source to deepen your understanding. The most difficult to understand parts of the code are often related to performance and other house-keeping, but you shouldn't let that shroud your understanding of the central pieces that are concerned with React’s core functionality.
Happy source reading!