DEV Community

Cover image for Reatom: Extensibility Saves the Day
Aleksei Gurianov
Aleksei Gurianov

Posted on

Reatom: Extensibility Saves the Day

What happens when your app isn't a typical SPA? When you're deploying to static hosting without server-side fallback configuration? When your components need to render inside Storybook without leaking navigation to the iframe's history? When your form needs to reset with new values after submission, not the original ones?

These aren't exotic edge cases. They're the stress tests that serious applications eventually face—and they're exactly where most state management libraries hit their limits.

Most libraries give you configuration for standard cases. Reatom gives you access to internal primitives. That's the difference between renting and owning. In this article, I'll prove what that ownership looks like through three practical recipes.

For me, the wall was Storybook. My components used Reatom's routing, and every click leaked to the iframe's History API—breaking Storybook's expectations. I needed routing to work internally, but the actual iframe URL had to stay untouched.

No library offers a config flag for that. But Reatom's design made the solution surprisingly clean.

Then I saw the comments under @artalar's article about Reatom v1000 release announcement: "Amazing! Where is the hash-router and memory-router?"

I already knew the answer. Not because Reatom ships hash routing out of the box—it doesn't. But because the same pattern that solved my Storybook problem applies directly.


What Makes Reatom Different

Before diving into solutions, a brief orientation for those less familiar with Reatom.

Reatom is built around a small set of primitives: atom, computed, effect, action, and extensions. The critical insight is that Reatom's own built-in features—routing, forms, persistence—are constructed from these same public primitives. There's no hidden internal API that library authors use while giving users a restricted external one.

When you extend or modify behavior, you're working with the same tools that power the library itself.

This "shared toolkit instead of black box" philosophy is exactly why unusual problems become solvable. The Storybook routing fix I'll describe wasn't a hack or workaround—it was a straightforward application of the primitives that reatomRoute itself relies on.


The Key to Ownership: Extensions, Not Wrappers

Here's the insight that unlocks everything that follows: the key to true ownership lies in using extensions—surgical modifications to existing primitives—rather than wrappers that build parallel infrastructure around them.

Why does this distinction matter so much? We'll see in vivid detail when we hit a truly complex problem in the forms example. For now, keep this in mind: a wrapper is a renter building partitions. An extension is an owner rewiring the electrical system.

First, let's establish the mental model that makes this possible.


A Mental Model for Architectural Thinking

When I fixed my Storybook routing issue, I followed a pattern—perhaps unconsciously at first. Looking back, I can extract a mental model for tackling "inconvenient" requirements.

This isn't just an algorithm. It's how an owner thinks and acts—as opposed to a renter who searches for config flags and files feature requests.

1. Exploration: Find the Primitive

What's the atom or action that sits at the boundary between your logic and the external constraint? This is usually a state holder that bridges your application code and the environment (browser, server, external system).

Think of it this way: when the lights go out, a renter flips switches in the apartment. An owner goes to the basement to find the circuit breaker.

In my Storybook case, I asked: "What connects routing logic to the browser's location APIs?" The answer was urlAtom—the primitive that everything in reatomRoute ultimately reads from or writes to.

2. Find Leverage Points: Identify Extension Hooks

Reatom provides several ways to hook into primitives:

  • atom.extend(withChangeHook(...)) — React to state changes
  • action.extend(withCallHook(...)) or addCallHook(action, ...) — React to action calls
  • getCalls(action) — Check if an action was called in the current transaction
  • Direct property overrides like urlAtom.sync.set(...) — Replace built-in behavior entirely

An owner doesn't just know where the circuit breaker is—they understand that they can rewire the whole system if needed.

For Storybook, the leverage points were urlAtom.sync (to disable history pushes) and withChangeHook (to restore the original URL after each state change).

3. Synthesis: Inject Your Behavior

Write environment-specific or feature-specific logic. Keep it composable. If it starts repeating, extract a reusable extension.

The injected behavior: "Don't leak navigation to the iframe's History API, but let internal routing state update normally."

Extension mental model

This mental model is the "skeleton" of Reatom problem-solving. Let's walk through it on real examples—starting with the Storybook case in full detail, then showing how the same pattern applies to completely different domains.


Applying the Mental Model: Routing Beyond the Happy Path

The Problem

Reatom's built-in routing uses the History API. The urlAtom reads from window.location and syncs changes via pushState/replaceState. Works great for most SPAs.

But then reality hits:

  • Storybook isolation — You're previewing route-dependent components. Every click leaks to the iframe's History API, overriding its URL and breaking Storybook's navigation and addon panel. But you need routing to work: links should be clickable, state should update, generated URLs should be testable for your design system.
  • Hash-based routing — Your app runs on static hosting, needs /#/path URLs for legacy reasons, or lives inside an Electron shell that doesn't play nice with history.

Both scenarios share the same root problem: the standard way of talking to the browser doesn't work. You need to change that conversation—without rewriting your routing logic.

Let's apply the mental model.

Step 1: Find the Primitive

The primitive is urlAtom—the atom that holds the current URL and bridges routing logic with the browser's location APIs. Everything in reatomRoute ultimately reads from or writes to this atom.

Step 2: Find the Leverage Points

urlAtom exposes several hooks:

  • urlAtom.sync — A settable action that controls how URL changes are pushed to the browser
  • urlAtom.set() — Direct state override for initial values
  • withChangeHook — React to any state change on the atom

Step 3: Inject Behavior

Storybook-safe routing:

// .storybook/preview.tsx
import { urlAtom, withChangeHook, noop } from '@reatom/core'

// Capture the original iframe URL
const originalHref = window.location.href

// Disable the sync that would normally push to history
urlAtom.sync.set(() => noop)

// Whenever urlAtom changes, restore the original URL
urlAtom.extend(withChangeHook(() => {
  window.history.replaceState({}, '', originalHref)
}))
Enter fullscreen mode Exit fullscreen mode

Clicking links in your story updates the routing state. Components respond. URLs are generated correctly for testing. But the iframe URL stays fixed. Storybook remains happy.

Hash-based routing (same mental model, different injection):

import { urlAtom, onEvent } from '@reatom/url'

const hashToPath = () =>
  new URL([window.origin, window.location.hash.replace(/^#\//, '')].join('/'))

const pathToHash = (path: string) => `#${path}`

// Set initial value (disables default History API init)
urlAtom.set(hashToPath())

// Subscribe to hash changes
onEvent(window, 'hashchange', () => urlAtom.syncFromSource(hashToPath(), true))

// Override sync to write to hash instead of history
urlAtom.sync.set(() => (url, replace) => {
  const path = url.href.replace(window.origin, '')
  requestAnimationFrame(() => {
    if (replace) {
      history.replaceState({}, '', pathToHash(path))
    } else {
      history.pushState({}, '', pathToHash(path))
    }
  })
})
Enter fullscreen mode Exit fullscreen mode

https://stackblitz.com/edit/reatom-hash-routing?file=src%2FApp.tsx

How Our Mental Model Worked

This solution didn't appear from nowhere. Let's trace the steps:

Notice what we didn't do. We didn't fork the library. We didn't submit a PR adding hash routing support. We didn't wait for a new version with a configuration flag. We didn't wrap reatomRoute in a custom factory that duplicates its API.

Instead, we applied the three-step mental model:

Step 1 — Find the primitive: We identified urlAtom as the bridge between routing logic and the browser's location APIs. Everything in reatomRoute ultimately reads from or writes to this atom.

Step 2 — Find leverage points: We discovered that urlAtom exposes sync (controls how changes push to the browser), set (direct state override), and withChangeHook (react to changes).

Step 3 — Inject behavior: We used these hooks to replace the standard History API logic with our custom implementation—hash-based, Storybook-safe, whatever the environment requires.

This is what ownership looks like in practice. We didn't search for a config flag—that's the renter's path. We found the foundational primitive and changed its behavior from within. The owner's path.

The reatomRoute helper doesn't know or care what we did. It just consumes urlAtom. By changing how that one primitive reads and writes, we've adapted to completely different environments.

Hash routing, memory routing, SSR—they're all the same type of problem. You're not learning separate tricks; you're learning one way of thinking.


Applying the Mental Model: Forms That Follow Your Rules

So far, we've seen how the mental model lets us redefine the boundary between the application and the browser. That's powerful—but it might look like something only for low-level integration tasks.

Now let's apply the same way of thinking to complex internal business logic: a user form with requirements no library anticipated. This example is architecturally more substantial than the routing case—we're not just tweaking a single atom, we're designing a reusable extension for a system of atoms and actions. Yet the underlying pattern remains the same.

The Problem

Every form library handles the basics: validation, dirty tracking, submit lifecycle. Reatom's reatomForm is no exception.

But then you hit the real world. Here's a pattern from a merchant dashboard I worked on:

Collapsible Card with form

A "Store Details" card. Collapsed, it shows the current store name. When expanded, it reveals an inline form. After saving, it should collapse back to the updated summary.

Three requirements that seem simple:

  1. Always active submit — Never disable the button. If clicked without changes, show "No changes to submit" instead of a mysterious grayed-out button.
  2. Reset with new values — After successful submit, the form's "initial" values must update to what was just saved. Otherwise the collapsed summary shows stale data.
  3. Auto-collapse on reset — When the form resets (save or cancel), the card collapses automatically.

The built-in resetOnSubmit doesn't work—it resets to original initial values, not the new values from the server.

Just like my Storybook routing problem: the standard behavior doesn't match my requirements. Same type of challenge, different domain. The "environment" here isn't the browser—it's business rules. But the mental model still applies.

Step 1: Find the Primitive

Forms in Reatom are built on atoms and actions. The key primitives here are:

  • myForm() — The atom holding current form values
  • myForm.submit.onFulfill — Action called after successful submission
  • myForm.reset — Action that resets form state

Just as urlAtom bridges routing and the browser, these primitives bridge form logic and the UI.

Step 2: Find the Leverage Points

  • withCallHook on submit.onFulfill — React to successful submissions
  • addCallHook on reset — React to form resets (for UI concerns)

Same types of hooks we used for routing—applied to a completely different domain.

Step 3: Inject Behavior

Starting simple (inline fix for one form):

// near form creation
myForm.submit.onFulfill.extend(
  withCallHook(() => myForm.reset(target()))
)
Enter fullscreen mode Exit fullscreen mode
// in reatomComponent
useEffect(() => {
  return addCallHook(form.reset, () => setOpen(false))
}, [form, setOpen])

<button onClick={(e) => {
  e.preventDefault()
  if (myForm.focus().dirty) {
    myForm.submit()
  } else {
    myForm.submit.error.set(new Error('No changes to submit'))
  }
}}>
Enter fullscreen mode Exit fullscreen mode

For one form, this works. Ship it.

When Patterns Repeat: Extract an Extension

When the pattern appears in five forms, scattered inline effects become a maintenance burden. Time to extract a reusable extension—just like Reatom's own built-ins do.

The key insight: we have two independent behaviors tangled together:

  1. Reset with new values — A form concern
  2. Collapse on reset — A UI concern

Your instinct here might be to create a wrapper factory—something like createEnhancedForm() that wraps reatomForm and adds the custom logic. It's the most familiar path. Almost a reflex.

Stop. This is the moment we've been building toward.

Remember the teaser from the beginning: extensions, not wrappers. Now you can feel why this matters.

Why a wrapper would fail you here:

Wrappers duplicate the entire API surface. Your createEnhancedForm factory must accept all of reatomForm's parameters and forward them correctly. That means duplicating types, handling optional parameters, keeping everything in sync. When reatomForm adds a new option next month, your wrapper breaks—or silently drops the new functionality.

Wrappers blur responsibility. Your factory now owns both "create the form" and "add enhanced behavior." Testing gets harder. Debugging gets harder. Another developer reading your code has to untangle what's standard behavior and what's your addition.

Wrappers have narrow applicability. What if you need the same enhanced behavior on a form created with different initial options? You need another wrapper. The pattern doesn't compose.

Why an extension wins:

An extension does exactly one thing: tweak behavior. It doesn't duplicate the API surface; it augments what's already there. withEnhancedForm doesn't care how the form was created—it just hooks into the primitives that reatomForm exposes.

When reatomForm evolves, your extension keeps working. You're hooking into stable extension points (submit.onFulfill, reset), not mirroring an entire factory signature.

This is the difference between building parallel infrastructure and making surgical adjustments to existing infrastructure. A renter builds partitions around the problem. An owner rewires the system.

You might think: "I can do something similar with React hooks." You can—but as a wrapper, not as an extension of the tool itself. The difference seems subtle in small examples. But at the scale of a real codebase, it's the difference between accumulating maintenance debt and leveraging the library's own architecture.

Let's see what this looks like in code:

import {
  action, reatomForm, 
  withCallHook, type Form,
  type FormInitState
} from '@reatom/core'
import type { FormEvent } from 'react'

// Extension 1: Reset form with current values after successful submit
export const withFormResetOnSubmit = <T extends FormInitState>(target: Form<T>) => {
  target.submit.onFulfill.extend(
    withCallHook(() => {
        const formState = target()
        target.reset(formState)
    })
  )
  return target
}

// Extension 2: Submit only when dirty, show error otherwise
export const withFormSubmitIfDirty = <T extends FormInitState>(target: Form<T>) => ({
  submitIfDirty: action((event?: FormEvent) => {
    event?.preventDefault()
    if (target.focus().dirty) {
      target.submit()
    } else {
      target.submit.error.set(new Error('No changes to submit'))
    }
  }, `${target.name}.submitIfDirty`),
})
Enter fullscreen mode Exit fullscreen mode

Each extension does exactly one thing. And here's the payoff—they compose:

const storeForm = reatomForm({
  shopName: '',
  partnerId: '',
}, {
  onSubmit: async (values) => api.updateStore(values),
  name: 'storeForm',
}).extend(withFormResetOnSubmit, withFormSubmitIfDirty)
Enter fullscreen mode Exit fullscreen mode

Need reset-on-submit but not submitIfDirty? Pass only the first extension. Need both? Pass both. Need submitIfDirty on a form that doesn't reset? Pass only the second. The extensions don't know about each other—they just hook into the same primitives.

This is what "narrow responsibility, wider applicability" looks like in practice.

The auto-collapse stays separate—placed right next to the collapsible state where it belongs:

useEffect(() => {
  return addCallHook(form.reset, () => setOpen(false))
}, [form, setOpen])
Enter fullscreen mode Exit fullscreen mode

How Our Mental Model Worked (Again)

Let's trace the same three steps we used for routing:

Step 1 — Find the primitive: The form is built on atoms and actions. The key primitives are myForm() (the state), submit.onFulfill (successful submission), and reset (state reset).

Step 2 — Find leverage points: We discovered withCallHook for reacting to action calls, and addCallHook for external reactions.

Step 3 — Inject behavior: We created two focused extensions—withFormResetOnSubmit and withFormSubmitIfDirty—each doing exactly one thing. They compose cleanly because they hook into the same primitives without knowing about each other.

Once again: ownership in action. We didn't file a feature request. We didn't wait for the library to support our use case. We understood the system at a fundamental level and shaped it to fit our needs.

Now compare the two cases:

In routing, our primitive was a single atom (urlAtom). In forms, it's an entire system of atoms and actions. In routing, we made a surgical tweak at the browser boundary. In forms, we designed composable extensions that can be mixed and matched.

But the mental model—the steps of thinking—stayed identical. We simply applied them at different scales.

That's the real takeaway: not two clever tricks, but one way of thinking that scales from a single atom to an entire subsystem. This is how an owner approaches problems.


The Trade-offs: What This Approach Costs

Experienced developers will ask: "Where's the catch?" Let's be honest about it.

This level of architectural control comes with trade-offs:

Higher entry barrier. To work at this level, you need to understand Reatom's primitives deeply—not just how to use atom and action, but how extensions compose, how hooks work, how the transaction system operates. The basics are simple; the architectural layer requires investment.

More discipline required. With great power comes the responsibility to use it wisely. You can override almost anything, but that doesn't mean you should. Teams need conventions about when to extend vs. when to contribute upstream, when to extract patterns vs. when to inline them.

Different mental model. If you're coming from libraries that do more for you (React Query, Redux Toolkit, etc.), the shift to "compose your own solution" can feel uncomfortable at first. You're trading implicit behavior for explicit control.

This isn't a silver bullet. For simple CRUD apps where you never hit edge cases, a more opinionated library might get you there faster. Choose that path without hesitation if you're confident the requirements won't evolve—one-time dashboards, short-lived experiments, internal tools with a single stakeholder, prototypes meant to be thrown away. When the cost of hitting a wall is low (rebuild the thing in a week), optimizing for initial speed makes sense.

But most production software doesn't stay simple. Requirements accumulate. Edge cases multiply. Stakeholders discover new needs. For complex domains where requirements evolve unpredictably—or where you simply don't know yet what "unpredictably" will look like—investing in this level of control pays dividends.


Building for the Unforeseen

This approach doesn't just solve immediate problems. It builds architectural foundations ready for requirements you can't predict yet.

Traditional frameworks have ceilings. You push until you hit configuration limits, then choose between ugly workarounds or painful migrations. With Reatom, you override environment-level behavior using the same primitives that power the built-ins. There's no ceiling—only building blocks you can stack as high as your domain requires.

That collapsible card form? Six months from now, business wants it to support multi-step editing with draft persistence. With the extension pattern already in place, you extend further. You don't rewrite.

The hash-routing adapter? When the mobile team needs a React Native version with deep linking, the same mental model applies. Find the primitive, find the leverage point, inject the behavior.

Compare this to systems that offer shallow customization—a few hooks or configuration options within predefined boundaries—versus Reatom's deep control over how primitives interact with the environment. You're not customizing behavior within boundaries; you're redefining the boundaries themselves.


Closing the Loop

Remember where we started? Static hosting that doesn't support the History API. Storybook iframes that break with every route change. Forms that need behaviors no library anticipated.

I promised to prove what "ownership" looks like in practice. The three recipes above are that proof.

But the goal of this article isn't to teach you another library. It's to change how you approach problems.

You're no longer a user of a library, searching for the right config flag, hoping the maintainers anticipated your use case. You're an architect who understands systems at a fundamental level and shapes tools to fit your domain.

When you next face an "impossible" requirement, your first thought won't be "does this library support it?" It will be "what's the primitive I need to work with?"

That's the shift from renting to owning. Not a different tool—a different way of thinking.

Find the primitive. Find the leverage point. Build exactly what your application needs.

Welcome home.


Links:

Top comments (1)

Collapse
 
artalar profile image
Artyom

Glad that you caught that vibe <3