• Home
  • About

From State to Relationships: The Declarative Overlay Pattern

Linear information flow and cognitive load redistribution, learned through overlay-kit


From State to Relationships: The Declarative Overlay Pattern

In this post, continuing from my previous one, I want to dig deeper into how declarative programming manifests in real-world code.

Just explaining theory would be boring, so let’s explore declarative programming in more detail through overlay-kit, a library that makes it easy to manage overlay elements like modals and toasts in React.

The Essence of Declarative Programming

I previously wrote about the essence of declarative programming. Using array methods like map or filter doesn’t automatically make your code declarative. True declarative thinking focuses on “What” instead of “How” — on relationships, not procedures.

But most React developers still haven’t escaped the procedural mindset of a decade ago when it comes to handling elements like modals and toasts.

We still use useState to create state, wire up event handlers, and manage the sequence of state changes. This is procedural thinking — focusing on temporal order: “first open the dialog, then wait for confirmation, and finally call the API.”

Just as you can think procedurally while using map, using useState doesn’t make your code declarative.

State Space and Cognitive Load

Remember the async data state example from the previous post? Managing loading, data, and error as independent boolean states creates logically impossible state combinations. The exact same problem occurs in overlay management.

const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<'confirmed' | null>(null);

// Logically impossible state combinations
// { isOpen: false, isLoading: true, result: null }
// → Closed but loading

// { isOpen: false, isLoading: false, result: 'confirmed' }
// → Closed but has a result

// { isOpen: true, isLoading: true, result: 'confirmed' }
// → Loading but already has a result

Three independent state variables theoretically produce 23=82³ = 8 combinations, but the number of logically valid combinations is far fewer. Every time developers write code, they must mentally verify: “Is this combination possible?”

The fundamental reason this is problematic is the increase in cognitive load. According to psychologist John Sweller’s cognitive load theory, human working memory is limited, with a finite amount of information it can process simultaneously. George Miller’s “magic number 7±2” comes from the same line of thinking.

So the problem with the overlay management pattern above is that you must track isOpen, isLoading, result, and the valid combinations between them just to understand how the overlay behaves. Right now it’s only 8 possible combinations, but as more states are added, the amount of information a developer must remember grows exponentially.

But this situation isn’t inherent complexity of the problem itself — it’s extraneous cognitive load created by the way we’ve chosen to express it. Just as a complex mathematical expression becomes easier to understand when rewritten with better notation, a small shift in approach can let us express overlays in a much simpler way.

The Relationship Between Input and Output

The key point I emphasized in my previous post was that declarative code expresses relationships, not the flow of time.

Recall the linear function y=2x+1y = 2x + 1. This isn’t an instruction to “multiply xx by 2 and add 1” — it declares the relationship between xx and yy. This relationship is a timeless truth. Whenever xx is 3, yy is 7 — always, everywhere.

The essence of overlays is the same. Look at the browser’s native API, window.confirm:

const result = window.confirm("Are you sure you want to delete this?");

Of course, window.confirm has the side effect of displaying UI, so it’s not a pure function. But the important thing is that this function doesn’t expose state management to the caller. There’s no isOpen state, no handleConfirm handler. How it’s implemented internally, what order things render in — all of that is hidden behind the abstraction. The developer can focus solely on the relationship: “what goes in, what comes out.”

In mathematics, a function represents a correspondence between sets. When we write f:ABf:A→B, we declare how each element of set AA corresponds to an element of set BB. This isn’t about computation — it’s about the relationship between structures.

window.confirm works the same way. It represents a correspondence from the set of message strings to the set of boolean values.

The Declarative Overlay Pattern

So what should we do? The answer is surprisingly simple: don’t treat overlays as state — treat them as functions.

The declarative overlay pattern brings the functional essence that window.confirm demonstrated into the React ecosystem.

const result = await overlay.openAsync(({ isOpen, close }) => (
  <ConfirmDialog
    isOpen={isOpen}
    onConfirm={() => close(true)}
    onCancel={() => close(false)}
  />
));

openAsync is a function that returns a Promise<T>.

If you’ve used Promises, you already know this pattern. Call an API, get a Promise back, await the result. openAsync works identically. Open an overlay, get a Promise back, wait for the user to respond. The only difference is that the response comes from a user instead of an API server.

In other words, it declares the relationship: “show an overlay and receive the user’s response.”

In this section, I want to examine three aspects of why the declarative overlay pattern is better than traditional state management.

From State to Relationships

In the previous post, I covered how to prevent impossible state combinations — explicitly enumerating possible states and letting the type system block invalid combinations.

But the declarative overlay pattern takes a different approach. Instead of constraining state combinations, it abstracts away the state itself.

Traditional state management deals with snapshots: “Is the dialog open right now?”, “Is it loading right now?”, “Is there a result right now?” It tracks the state at the current point in time and changes these states one by one to produce the desired behavior. It’s like creating animation by stitching together individual photographs.

But think about it — what we actually want to know is whether the user clicked “confirm” in the dialog, not whether the dialog is currently open or closed.

The intermediate states are just means to an end. We don’t care about the current state — we want the final result.

The declarative overlay pattern eliminates this intermediate process. What used to require managing multiple complex states is expressed as a single action — opening an overlay — and the focus is solely on receiving the result.

// Open the dialog
setIsOpen(true);

// Somewhere later, close the dialog and set the result
setIsOpen(false);
setResult('confirmed');
// Express only the relationship between action and result
const result = await overlay.openAsync(...);

Why is this better?

First, the code directly expresses intent. The intention “I want to get user confirmation” is right there in the code. There’s no need for a developer to mentally combine three state variables and deduce “ah, this is trying to get confirmation.”

Second, there’s less room for errors. The first approach has plenty of openings for human error — forgetting to set isOpen back to false, setting result without closing the dialog, and so on. Abstracting these operations into a function makes such mistakes structurally impossible.

Third, you can focus on the result of a change rather than the process of change. It expresses what happens, not how state changes. In other words, it’s declarative.

Put another way, the declarative overlay pattern replaces tracking what values individual state variables hold (state snapshots) with expressing what result comes from what action (input-output relationships).

Redistribution of Cognitive Load

Code readability is often considered a matter of subjective taste. But psychologist John Sweller’s cognitive load theory shows that it’s not just about taste — it’s a matter of cognitive science.

Look at the traditional state management approach again. Developers must simultaneously track multiple state variables like isOpen, isLoading, and result. To understand how the module behaves, they must mentally simulate what order these variables change in and which combinations are valid.

In cognitive load theory, this is called extraneous load. Extraneous load in code isn’t the inherent complexity of the problem — it’s unnecessary complexity created by the way we’ve chosen to express it. The essential problem of showing an overlay and getting user confirmation is simple, but expressing this through multiple state variables artificially inflates the complexity.

Recall George Miller’s “magic number 7±2.” Humans can only hold about 7 items in working memory at once. But with just three state variables, the possible combinations already reach 8, and tracking which of those are valid pushes us to our cognitive limits.

The declarative overlay pattern removes this extraneous load. Developers no longer need to remember combinations across multiple state variables — they only need to remember one function call, the relationship between input and output, which reduces the load.

I believe that expressing the same problem with less cognitive load — removing unnecessary complexity so developers can focus on the essential problem — is the essence of writing readable code.

But there’s something important not to misunderstand: the complexity hasn’t disappeared — it’s merely been redistributed.

The implementers of the overlay-kit library still have to deal with complex logic like Promise management and state synchronization. But this gnarly implementation only needs to be done once.

Through the contributions of a few, countless developers can work with overlays at low cognitive load. This is the value that abstraction gives us.

Composition and Control Flow

Consider a situation where you need to collect user input through multiple overlays. With the traditional approach, the logic looks like this:

function CreateUserFlow() {
  const [step, setStep] = useState(1);
  const [info, setInfo] = useState(null);
  const [preference, setPreference] = useState(null);

  const handleInfoSubmit = (data) => {
    setInfo(data);
    setStep(2);
  };

  const handlePreferenceSubmit = (data) => {
    setPreference(data);
    setStep(3);
  };

  const handleConfirm = async () => {
    await api.createUser({ ...info, ...preference });
    setStep(1);
    setInfo(null);
    setPreference(null);
  };

  return (
    <>
      {step === 1 && <UserInfoForm onSubmit={handleInfoSubmit} />}
      {step === 2 && <PreferenceForm onSubmit={handlePreferenceSubmit} />}
      {step === 3 && <ConfirmDialog onConfirm={handleConfirm} />}
    </>
  );
}

This is a simple flow that collects user info, preferences, and a final confirmation before calling an API. But the information flow is scattered throughout the code. Developers must follow how step state changes, what each handler does, and what conditions control the JSX rendering — all just to understand the overall flow.

Now look at the same logic implemented with overlay-kit’s openAsync:

async function createUser() {
  const info = await overlay.openAsync(({ isOpen, close }) => (
    <UserInfoForm isOpen={isOpen} onSubmit={close} />
  ));

  const preference = await overlay.openAsync(({ isOpen, close }) => (
    <PreferenceForm isOpen={isOpen} onSubmit={close} />
  ));

  if (await confirmCreation(info, preference)) {
    await api.createUser({ ...info, ...preference });
  }
}

The biggest difference is that the information flow is linear. The first line collects user info, the second line collects preferences, and the third line gets confirmation before calling the API. The order you read the code from top to bottom is the execution order.

This is possible because openAsync returns a Promise. Promises let you wait for an asynchronous operation to complete. Wait for the first overlay to close, then open the second. When the second closes, move to the third. Each step executes sequentially, and information flows from top to bottom.

Each step is also independent, meaning the concerns have low coupling and high cohesion. The user info form only collects user info, the preference form only collects preferences, and how to stitch them together is decided by the createUser function. Each component simply calls close and doesn’t need to know where the result goes.

Furthermore, since openAsync is just a function that returns a value, it integrates naturally with standard control flow. Need conditional logic? Use if. Need error handling? Use try/catch. Need iteration? Use for. The control flow you learned in Programming 101 works as-is — no special overlay patterns to learn.

Thanks to these function characteristics, reusing specific UX flows is also straightforward:

function confirmAction(message: string) {
  return overlay.openAsync(({ isOpen, close }) => (
    <ConfirmDialog
      message={message}
      isOpen={isOpen}
      onConfirm={() => close(true)}
      onCancel={() => close(false)}
    />
  ));
}

if (await confirmAction('Are you sure you want to delete this?')) {
  await api.deleteUser();
}

if (await confirmAction('Reset your settings?')) {
  await api.resetSettings();
}

This is the power of treating overlays as functions. You can express information flow linearly, integrate naturally with standard control flow like if, try/catch, and for, and reuse is as simple as extracting a function.

Extending Declarative Thinking

In 2013, React freed us from direct DOM manipulation and introduced a world where we could declaratively express the structural relationship between data and UI through JSX.

But sadly, within the same React codebase, we write components declaratively while handling overlays procedurally.

// Components: declarative
<UserProfile user={user} />

// Overlays: procedural
const [isOpen, setIsOpen] = useState(false);
const handleOpen = () => setIsOpen(true);
const handleClose = () => setIsOpen(false);

Why did this split happen? Because overlays appear to be bound to time. A user clicks a button, a dialog opens, then the user clicks confirm, an API is called, and the response arrives, the dialog closes. So we thought we needed state to manage this sequence.

But look at window.confirm again:

if (window.confirm('Are you sure you want to delete this?')) {
  deleteItem();
}

Of course, the effect this code produces also involves temporal ordering — waiting for the user to click confirm in the dialog. But we don’t express this as a flow of time in the code. We express it as a function that takes a string and returns a boolean — a relationship between a string and a boolean.

This is exactly what the declarative overlay pattern does. It transforms temporal sequences into relationships between conditions and results.

// Procedural: "first open, wait, close, then..."
setIsOpen(true);
// ... somewhere else
setIsOpen(false);
// ... and then
deleteItem();
// Declarative: "if confirmed, delete"
if (await overlay.openAsync(...)) {
  deleteItem();
}

Now we can extend React’s declarative philosophy to overlays. Just as components declare the relationship between data and UI, overlays declare the relationship between user responses and the next action.

And this consistency matters. If you think declaratively when reading components but must switch to procedural thinking for overlays, cognitive dissonance arises. But with the declarative overlay pattern, the entire codebase is unified under a single way of thinking.

Closing Thoughts

overlay-kit is a library that implements this pattern.

Its value isn’t in reducing lines of code — it’s in bringing the simplicity that window.confirm demonstrated into the React ecosystem. It simply makes something that was easy 10 years ago but got complicated along the way easy again.

In my previous post, I said declarative programming is a way of thinking, not a tool. Just as using map or filter doesn’t make code declarative, using useState doesn’t make it procedural either. What matters is what you’re expressing.

The declarative overlay pattern discussed in this post is the same. What matters isn’t the openAsync API — it’s “do you see overlays as state, or as relationships?”

See them as state, and you must track variables like isOpen, isLoading, and result. See them as relationships, and you only need to think about input and output. Expressing the same problem differently is enough to change the cognitive load.

And I believe that removing unnecessary complexity so developers can focus on the essential problem is the essence of good abstraction.

관련 포스팅 보러가기

Sep 07, 2025

Misconceptions About Declarative Programming

Programming
Dec 15, 2019

Functional Thinking – Breaking Free from Old Mental Models

Programming/Architecture
Jan 27, 2020

How Can We Safely Compose Functions?

Programming/Architecture
Jan 05, 2020

How to Keep State from Changing – Immutability

Programming/Architecture
Dec 29, 2019

Pure Functions – A Programming Paradigm Rooted in Mathematics

Programming/Architecture