Import on Interaction: Load When User Interacts

Medium•

Import on interaction is a practical frontend performance pattern built on dynamic import(). Instead of eagerly shipping all application code, you defer optional feature bundles until a real user action suggests the code is needed.

This improves startup performance by reducing the amount of JavaScript that must be downloaded, parsed, compiled, and executed on the initial page load.

The tradeoff is simple: you save initial cost, but the first interaction may now pay a loading cost. Good implementations manage that tradeoff with caching, prefetching, loading UI, and careful candidate selection.

Quick Decision Guide

Senior-Level Decision Guide:

- Use import on interaction for optional, non-critical, heavy features. - Trigger loading on click for correctness, or on hover/focus for earlier intent. - Cache the loaded Promise or module so you do not repeat work. - Always handle loading and failure states. - Do not defer code that is required for the initial experience.

Interview framing: Import on interaction is an intent-driven code-splitting strategy that reduces initial JavaScript cost by loading secondary features only when users are likely to need them.

What Import on Interaction Means

Core Idea

With dynamic imports, you can load code at runtime:

const module = await import('./HeavyFeature');

Instead of importing that module during initial page load, you wait until the user interacts with a trigger.

Examples:

•click Open Filters
•focus a search box
•hover over Open Chat
•tap a video thumbnail

Why this helps

If the feature is not used by every visitor, there is no reason to force all users to pay its JavaScript cost up front.

This is especially valuable for:

•complex UI widgets
•editor libraries
•charting packages
•auth flows
•secondary panels and overlays

Basic Click Pattern

Load on Click

The simplest version loads a module on the first click and reuses it afterward.

function SettingsButton() {
  const [Modal, setModal] = useState(null);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);

  const handleOpen = async () => {
    if (!Modal) {
      setLoading(true);
      try {
        const mod = await import('./SettingsModal');
        setModal(() => mod.default);
      } finally {
        setLoading(false);
      }
    }

    setOpen(true);
  };

  return (
    <>
      <button onClick={handleOpen}>
        {loading ? 'Loading…' : 'Open Settings'}
      </button>
      {open && Modal && <Modal onClose={() => setOpen(false)} />}
    </>
  );
}

Interview takeaway

This is the cleanest mental model:

•initial bundle stays smaller
•first interaction loads the code
•later interactions reuse it

Hover or Focus as Intent Signals

Prefetch Before Click

Sometimes waiting until the exact click makes the UI feel delayed.

A common improvement is to start loading when the user shows intent slightly earlier:

•mouseenter
•focus
•touchstart in some mobile flows
function ChatTrigger() {
  const moduleRef = useRef(null);
  const promiseRef = useRef(null);

  const prefetch = () => {
    if (!promiseRef.current) {
      promiseRef.current = import('./ChatWidget').then(mod => {
        moduleRef.current = mod.default;
        return mod;
      });
    }
  };

  const handleClick = async () => {
    if (!moduleRef.current) {
      await prefetch();
    }
    moduleRef.current?.open();
  };

  return (
    <button onMouseEnter={prefetch} onFocus={prefetch} onClick={handleClick}>
      Open Chat
    </button>
  );
}

Why this works

Hover and focus often happen a fraction of a second before click. That small window can hide some or all of the network and parse cost.

Caching the Loaded Module

Avoid Duplicate Loading Logic

One of the most important implementation details is caching.

Even though module loaders typically avoid re-downloading the same chunk after it is resolved, your UI code should still avoid repeatedly creating awkward loading flows.

A good pattern is to cache either:

•the resolved module
•or the in-flight Promise
let modalPromise;

function loadModal() {
  if (!modalPromise) {
    modalPromise = import('./Modal');
  }
  return modalPromise;
}

Why caching the Promise is useful

If a user clicks twice quickly, both calls can share the same in-flight Promise instead of starting separate control flow paths.

Good vs Bad Candidates

Good Candidates

Import on interaction works well for features that are:

•optional
•secondary
•heavy
•not needed for above-the-fold rendering
•triggered by a clear user action

Examples:

•settings modal
•comment editor
•export dialog
•onboarding flow
•share sheet
•support chat
•advanced filters

Bad Candidates

Avoid this pattern for code that is:

•required for first render
•part of primary navigation
•needed immediately after page load
•tiny enough that splitting is not worth the complexity
•latency-sensitive in a way that first-use delay is unacceptable

Examples:

•header navigation
•core product shell
•essential form validation needed right away
•above-the-fold CTA logic

Tradeoffs and UX Costs

The Main Tradeoff

Import on interaction does not remove cost. It moves cost from initial load to first use.

That is usually good when the feature is optional, but it introduces a new UX question:

> Is the first interaction still fast enough?

Typical UX issues

•click feels delayed while chunk loads
•modal button appears unresponsive
•input focus triggers a noticeable pause
•poor network causes frustration on first use

How to reduce that cost

•show loading feedback
•prefetch on hover or focus
•keep deferred chunks reasonably small
•avoid splitting extremely tiny modules into many micro-chunks
•use retries or graceful fallback for failures

React Patterns

Reusable Hook

A reusable hook can centralize loading, caching, and loading state.

function useLoadOnInteraction(loader) {
  const promiseRef = useRef(null);
  const [Component, setComponent] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const load = useCallback(async () => {
    if (Component) return Component;

    if (!promiseRef.current) {
      setIsLoading(true);
      setError(null);
      promiseRef.current = loader()
        .then(mod => {
          const resolved = mod.default || mod;
          setComponent(() => resolved);
          return resolved;
        })
        .catch(err => {
          promiseRef.current = null;
          setError(err);
          throw err;
        })
        .finally(() => {
          setIsLoading(false);
        });
    }

    return promiseRef.current;
  }, [loader, Component]);

  const prefetch = useCallback(() => {
    if (!promiseRef.current && !Component) {
      void load();
    }
  }, [load, Component]);

  return { Component, isLoading, error, load, prefetch };
}

Why this is a strong interview answer

It shows:

•Promise caching
•loading state
•retry support by clearing failed Promise state
•reusable abstraction instead of copy-pasted logic

Real-World Examples

Example 1: Rich Text Editor

A comment box may start as a lightweight textarea, then load the rich editor only on focus.

function CommentBox() {
  const { Component: Editor, load, isLoading } = useLoadOnInteraction(
    () => import('./RichTextEditor')
  );
  const [active, setActive] = useState(false);

  const handleFocus = async () => {
    setActive(true);
    await load();
  };

  return active && Editor ? (
    <Editor />
  ) : (
    <textarea
      onFocus={handleFocus}
      placeholder={isLoading ? 'Loading editor…' : 'Write a comment...'}
    />
  );
}

Example 2: Video Player

Video thumbnails can defer the player library until the user actually presses play.

Example 3: Advanced Filters

Filter drawers in dashboards are often good candidates because many users never open them.

Example 4: Auth Modal

Sign-in flows can be split if the landing page does not need auth logic immediately.

Performance Impact

Why It Improves Performance

Reducing initial JavaScript helps in multiple ways:

•smaller download
•less parse and compile time
•less main-thread work during startup
•faster route hydration or interactivity on slower devices

Important nuance

This pattern is most valuable when the deferred module is meaningfully large or costly.

If a feature is tiny, splitting it may add complexity and extra request overhead without much real gain.

What to measure

You should validate impact with real profiling:

•bundle analyzer output
•network waterfall
•performance traces
•user timing around interaction-to-ready latency

A strong engineering answer always includes measurement, not just theory.

Best Practices

1. Cache in-flight and resolved modules

Do not rebuild the same loading logic every interaction.

2. Use early intent signals carefully

Hover and focus can improve responsiveness, but do not over-prefetch every possible feature.

3. Provide clear loading states

If a click now triggers code loading, the user needs visible feedback.

4. Handle failures

Dynamic imports can fail because of network issues or chunk mismatches.

5. Split by meaningful feature boundaries

Do not create dozens of tiny fragments unless they reflect real usage boundaries.

6. Keep critical paths eager

Defer optional experiences, not the core page shell.

Common Pitfalls

Pitfall: loading on mount instead of interaction

useEffect(() => {
  import('./Module');
}, []);

That is lazy-on-mount, not import on interaction.

Pitfall: no loading feedback

A user clicks and nothing appears to happen.

Pitfall: deferring essential logic

The site may benchmark better but feel worse.

Pitfall: ignoring mobile intent patterns

Hover does not exist on many touch devices, so click or focus-based strategies matter more there.

Interview Scenarios

Scenario 1: Why not lazy load everything?

Because some code is needed immediately, and too much fragmentation can hurt user experience.

Scenario 2: When is hover-prefetch better than click-only?

When you want to reduce first-use latency and the hover is a strong signal of likely activation.

Scenario 3: What is the main tradeoff?

You reduce startup cost but may add first-interaction latency.

Scenario 4: How would you explain this in one sentence?

Import on interaction is an intent-based code-splitting strategy that loads optional code only when the user is likely to need it.

Scenario 5: What makes a good candidate?

A heavy, optional feature triggered by explicit user action, such as a modal, editor, or chat widget.

Key Takeaways

1Import on interaction defers optional code until the user shows intent.
2It is built on dynamic import() and code splitting.
3The biggest benefit is a smaller initial JavaScript cost.
4The main tradeoff is first-use latency.
5Click is the simplest trigger, while hover and focus can be used for prefetching.
6Cache the Promise or resolved module to avoid duplicate loading work.
7Good candidates are heavy, optional, interaction-driven features.
8Critical-path UI and tiny modules are often poor candidates.
9Loading and error states are required for a good user experience.
10Measure both startup improvement and interaction-to-ready latency.