Skip to content
⚡ LIVE From Lizard to Wizard · Wednesday, August 5 · LIMITED SEATS Save my seat →

· reactjs · 23 min read

Component Communication Patterns in React Applications

React gives you a lot of ways to make two components share data but it gets more and more complicated based on the data and how far apart the components are. Lets see the different ways components can communicate with each other.

Neciu Dan

Neciu Dan

Hi there, it's Dan, a technical co-founder of an ed-tech startup, host of Señors at Scale - a podcast for Senior Engineers, Organizer of ReactJS Barcelona meetup, international speaker and Staff Software Engineer, I'm here to share insights on combining technology and education to solve real problems.

I write about startup challenges, tech innovations, and the Frontend Development. Subscribe to join me on this journey of transforming education through technology. Want to discuss Tech, Frontend or Startup life? Let's connect.

Share:

Communication is the cornerstone of civilization. And just like humans need to communicate, so do our components.

Now there are many books on Architecture and Design Patterns for communicating between modules (if you are interested, the list is at the bottom), but we dont have anything similar for Frontend, especially React.

The problem is that React gives you a lot of different ways to make two components share something, and which one is right almost always comes down to two things people rarely stop to think about:

  • How far apart the two components actually are.
  • What kind of value is moving between them.

A theme that the whole app reads is a completely different problem from a filter that two sibling components share, which is different again from a user record that really lives on the server.

This article will walk through the options, starting with the closest distance between components.

Props and callbacks

The simplest situation is this.

You have a parent component that renders a child, and both need to agree on a single value. They’re sitting right next to each other in the tree, so there’s almost no distance to cover, and React already gives you everything you need to cover it.

Data flows down as props from parent to child, and up as callbacks—no need for fancier solutions when components are close.

Here’s that shape in practice, with a filter that a parent owns and two children care about:

function FilterBar({ filter, onFilterChange }) {
  return (
    <select value={filter} onChange={e => onFilterChange(e.target.value)}>
      <option value="all">All</option>
      <option value="active">Active</option>
    </select>
  );
}

function TodoPage() {
  const [filter, setFilter] = useState('all');

  return (
    <>
      <FilterBar filter={filter} onFilterChange={setFilter} />
      <TodoList filter={filter} />
    </>
  );
}

TodoPage holds the filter, FilterBar reads and updates it, and TodoList uses it for display.

The state should live at the lowest point where both children have access, which is the shared parent. “Lifting state up” is the go-to approach when siblings need to share a value.

The problems start when that value has to travel through components that have no interest in it whatsoever, just to reach the one that does. Something like this:

function Page({ user }) {
  return <Layout user={user} />;
}

function Layout({ user }) {
  return <Sidebar user={user} />;
}

function Sidebar({ user }) {
  return <Avatar user={user} />;
}

Layout and Sidebar don’t use user; they’re just passing it down to Avatar. This is called prop drilling and is a common reason to consider a different communication pattern.

But before reaching for other tools, remember that a three-level prop drill can often be solved with composition.

Instead of passing data down, pass the rendered element as children so the data doesn’t need to move through uninterested components.

function Page({ user }) {
  return (
    <Layout>
      <Sidebar>
        <Avatar user={user} />
      </Sidebar>
    </Layout>
  );
}

Now the user goes directly to where it’s needed, skipping Layout and Sidebar.

Colocation

Before connecting components, check whether they really need to communicate.

Sometimes the state is moved up “just in case” another child might need it, but if that sibling never appears, the state ends up being shared for no reason.

Here’s the kind of thing I mean:

function Dashboard() {
  const [searchTerm, setSearchTerm] = useState('');
  const [isMenuOpen, setIsMenuOpen] = useState(false);

  return (
    <>
      <SearchBox value={searchTerm} onChange={setSearchTerm} />
      <Results searchTerm={searchTerm} />
      <Menu isOpen={isMenuOpen} onToggle={setIsMenuOpen} />
    </>
  );
}

searchTerm belongs in the Dashboard because both children use it. isMenuOpen, though, is only used by Menu but still lives in the parent.

This means Dashboard and its children re-render unnecessarily when isMenuOpen changes.

The fix is to send that state back down to where it’s actually used:

function Menu() {
  const [isOpen, setIsOpen] = useState(false);
  return <button onClick={() => setIsOpen(o => !o)}>{/* ... */}</button>;
}

Colocation means moving the component as close as possible to the component that uses it, never higher than needed.

This applies both within a component and across the tree; use the smallest scope necessary.

Colocating the state improves performance, too. State updates re-render the component and its children; if the state is too high, unnecessary re-renders happen.

So before you reach for any of the tools in the rest of this article, do the cheaper thing first and confirm the two components really do need to share something. In my experience, a good chunk of the “shared state” I’ve deleted over the years was never shared by anything.

Imperative calls

Sometimes a parent doesn’t want to share any state with a child whatsoever. What it wants is to reach in and tell that child to do something. Play this video. Focus this input. Scroll to this particular row. Open this dialog.

The tool for that is a ref paired with useImperativeHandle.

The child decides on a small, deliberate set of methods to expose, and the parent gets to call them directly.

In React 19, ref is finally just a regular prop, so the child can read it straight out of its props like anything else:

function VideoPlayer({ src, ref }) {
  const videoRef = useRef(null);

  useImperativeHandle(ref, () => ({
    play: () => videoRef.current.play(),
    pause: () => videoRef.current.pause(),
  }));

  return <video ref={videoRef} src={src} />;
}

function Page() {
  const playerRef = useRef(null);
  return (
    <>
      <VideoPlayer ref={playerRef} src="/clip.mp4" />
      <button onClick={() => playerRef.current.play()}>Play</button>
    </>
  );
}

If you’re still on React 18 or earlier, the only difference is that the same component has to be wrapped in forwardRef in order to receive that ref, since ref wasn’t a normal prop back then.

So you’d write const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) { ... }) and everything else stays exactly the same.

To know when to use, think about it like this: if you’d naturally say “do X,” it’s imperative, and this is your tool. If you’d say “we both need to know Y,” then it’s a state.

Context

Now, let’s say the distance between components has genuinely grown.

You’ve got a value that’s needed by a lot of components scattered at a lot of different depths, and you’ve already tried the composition trick from earlier, and restructuring everything around children would twist your component tree into something unreadable.

On top of that, the value barely ever changes once it’s set.

The classic examples here are the current theme, the logged-in user, the active locale, and the accent color your design system provides.

These are values that get read all over the place but written almost never, and that combination is exactly what context was built for.

It lets any component reach in and grab the value without you having to thread it through every layer in between.

const ThemeContext = createContext('light');

function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={theme}>
      <Header />
      <Main />
    </ThemeContext.Provider>
  );
}

function ThemeToggle() {
  const theme = useContext(ThemeContext);
  // ...
}

The nice part is that any component living under that provider can call useContext and read the value directly.

Here’s the catch, though, and it’s the reason context has the slightly bruised reputation it carries. Whenever the value in a context changes, every single component consuming that context re-renders.

All of them, no exceptions.

And before you assume React.memo will rescue you here, it won’t, which catches a lot of people off guard. The thing about memo is that it skips a re-render when a component’s props haven’t changed.

But a context consumer isn’t reacting to props at all; it’s reaching past them and subscribing to the context directly. So when the value’s identity changes, every consumer re-renders straight through any memo boundary you’ve placed between them and the provider, as if it weren’t there.

Which means the moment you do something like this, you’ve quietly built yourself a performance trap:

// Don't do this
const AppContext = createContext();

function App() {
  const [user, setUser] = useState();
  const [theme, setTheme] = useState();
  const [notifications, setNotifications] = useState([]);

  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme, notifications, setNotifications }}>
      {/* ... */}
    </AppContext.Provider>
  );
}

Every time App re-renders, that value={{ user, theme, notifications, ... }} builds a brand-new object with a brand-new identity, even when none of the actual values inside it have changed (That’s how JavaScript objects work).

React compares the new object to the previous one, sees they have different identities, concludes the context changed, and forces every child in the context to re-render.

There are ways out, but each one is a bit of a chore.

You can split that one context into several narrow ones, so a change to the notifications context doesn’t wake up everyone reading the theme.

You can wrap the value object in useMemo, so it retains its identity when nothing relevant has changed.

You can even separate the state context from the dispatch context, so the components that only ever fire actions don’t re-render every time the state moves underneath them.

But notice what’s happening once you’re stacking split contexts and useMemo to hand-build selector behavior that context refuses to give you natively.

Context was designed to inject values that change slowly, not to act as the backing store for a state that updates many times a second across a wide tree.

When you find yourself fighting it that hard, you’ve outgrown it.

A global store

So you’ve arrived at the situation that the context couldn’t handle.

The value changes frequently, plenty of components across the tree each read a different slice of it, and what you really need is for each of those components to re-render only when its own slice changes.

You might just need a State Management Library.

The one I reach for first these days is Zustand, mostly because it’s small. (I have this podcast episode with the author of Zustand, Jotai, and Waku, where we talk in depth on why it’s small, check it out here)

import { create } from 'zustand';

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  clear: () => set({ items: [] }),
}));

function CartCount() {
  // Re-renders only when items.length changes, not on every store update
  const count = useCartStore((state) => state.items.length);
  return <span>{count}</span>;
}

function AddButton({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>Add</button>;
}

When CartCount subscribes with (state) => state.items.length, it’s telling Zustand to wake it up only when that specific number changes.

And AddButton subscribes to addItem, which is a function whose identity never changes, so it effectively never re-renders from store updates.

The store is just a hook you call. And because it isn’t tied to React’s render cycle, you can also read it from outside a component entirely with useCartStore.getState(), which sounds like a footnote until the first time you need to call some store logic from an event handler or a utility that isn’t a component, and it’s just there waiting for you.

I do want to add one caveat right here.

If you write a selector that builds and returns a new object or array every time it runs, you are not using Zustand corectly and have the same problem as Global Context.

Say you write useCartStore((s) => s.items.filter(i => i.active)).

That filter returns a freshly created array on every call, so it has a new identity each time. Zustand compares it to the previous one by identity, sees two different references, and concludes the value changed, which makes your component re-render on every render, forever.

The solution is the hook useShallow from zustand/shallow, which compares the contents instead of the identity.

Selectors that return a single primitive value, like items.length or a single string field, are fine as they are; it’s only those that assemble a new object or array that need the extra help.

Now, Zustand isn’t the tool you can use.

Redux Toolkit (the new Redux library) is still genuinely the right call for a particular kind of application: the large, long-lived one with many engineers touching it, where DevTools time-travel is very useful. (Fun Fact: I also have a podcast episode with Mark Erikson, creator of Redux Toolkit and long-time maintainer of Redux, check it out here)

And there’s a third state management use case you should know about.

Sometimes your shared state isn’t really one object at all; it’s more like a graph of many small, independent values.

For that, Jotai (also from Daishi Kato, the creator of Zustand) models states as atoms you compose, and components subscribe at the level of an individual atom, so a change to one atom only ever touches the components reading that specific atom.

It’s the same flow Zustand gives you, just approached from the bottom up instead of the top down.

The rule sitting underneath all three of these is the same: a global store is for a state that is, honestly, actually global.

The mistake people make is borrowing the store’s ability to reach anywhere as a shortcut for a state that isn’t global at all.

And the single most common thing stuffed into one of these stores is server data, which has its own dedicated tool, and we will talk about next.

Server state

Here’s a thing that took me embarrassingly long to internalize: the single largest pile of what we call “shared state” in most apps doesn’t actually belong to the app at all.

It belongs to the server, and all your app is really doing is holding a copy.

Think about what tends to go into a global store. A user object. A list of orders. The contents of a cart that’s really persisted in a database somewhere.

So a team drops all of that into their store, and then they have to write a whole machine around it to keep that copy honest, the loading flags, the error flags, the refetch logic, the cache invalidation, every bit of it by hand.

The store slowly fills up with data that has a real owner living somewhere else entirely, and every component reading it is reading a snapshot that can be stale the very instant the database changes underneath it.

The reason this is so awkward is that server state behaves very differently from client state.

It’s owned remotely, so you’re never the authority on it. It goes stale on its own, without you having to touch it.

Someone else can change it while you’re sitting there looking at your copy.

And there’s always that gap in time between asking for it and actually receiving it. A plain useState or even a Zustand store has no concept of any of those four things, so when you try to model server data with them, you end up reimplementing all of it, usually badly.

This is where TanStack Query comes in, and I wrote a whole gentle introduction to it you can read here.

To make it work, you create a QueryClient once at the root of your app, and that’s the shared cache every hook in the tree reads from.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Routes />
    </QueryClientProvider>
  );
}

With that provider in place, any component anywhere in the tree can read from the same cache like this:

function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;
  return <div>{user.name}</div>;
}

Take a second to notice everything that isn’t in there.

There’s no useState holding the data, no useEffect kicking off the fetch, no loading boolean you have to flip yourself, no cleanup logic to throw away a stale response that arrived too late.

But the part that’s easy to miss, and the part that earns this a place in an article about communication, is what happens when more than one component wants the same data.

You could have ten components scattered all over the tree, each one calling useQuery with that same ['user', userId] key.

Because they’re all sharing a single QueryClient, only one network request actually fires within the dedupe window, and the rest just read the cache entry that request fills.

They all update together the instant that the entry changes.

Writes to the server state fit the same picture. A mutation runs the update against the server, then tells the cache which keys are now stale, and every component subscribed to those keys handles its own refetch and re-render from there:

function AddToCart({ product }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (item) => postCartItem(item),
    onSuccess: () => {
      // Everything reading ['cart'] is stale now; refetch it
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });

  return (
    <button
      disabled={mutation.isPending}
      onClick={() => mutation.mutate(product)}
    >
      {mutation.isPending ? 'Adding...' : 'Add to cart'}
    </button>
  );
}

And once your server state lives in a query cache where it actually belongs, you’ll usually find your global store shrinks down to almost nothing.

So much of what was crammed into it was never client state in the first place; it was server state, and once you remove it, what’s left behind is the genuinely client-only, genuinely global stuff, which tends to be a far shorter list than the store you started with.

URL State

A lot of what we call application state isn’t really state about the application at all; it’s just a description of what the user is looking at right now.

The filter is active. The page they’re on is in a paginated table. The search query they typed. The tab they selected. Whether a detail panel is open or closed. None of that is really yours to hold onto in memory, because it’s just a snapshot of the current view.

And for that whole category of “what am I currently looking at,” the browser has already handed you a global, shareable, persistent store that you can use: the URL.

The moment you move that state into the URL, three separate problems quietly solve themselves at once.

The view becomes shareable because the link now carries the state, so someone can paste it to a colleague and land on the exact same view, and it survives a refresh because the state was never trapped in memory to begin with.

And the back button starts working the way users expect, because each state you set becomes its own entry in the browser’s history.

The best part is that you get all of this precisely for the kind of state users most expect to be able to bookmark and send around.

The raw way to do this is URLSearchParams, but it’s string-only and genuinely miserable to keep in sync by hand, so in practice I reach for nuqs, which makes a search param behave like a typed useState:

import { useQueryState, parseAsInteger } from 'nuqs';

function ProductList() {
  const [search, setSearch] = useQueryState('q');
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));

  // Both now live in the URL: /products?q=shoes&page=2
}

I believe this belongs in an article about communication because it connects two components that are far apart.

Picture a filter bar on one side of your layout and a results table on the other. With the URL holding the state, they don’t need a shared parent, a context, or a store to stay in step with one another. They simply both read and write the same URL parameter.

The URL is the shared state, and the router is the broadcast mechanism quietly notifying everyone when it changes, so you’ve got two distant components staying perfectly coordinated through a channel that also happens to be the address bar at the top of the window.

Where do you draw the line on this? The test I use is simple.

If the state describes the view and a user would reasonably want to link to it, it belongs in the URL.

But if it’s an ephemeral interface state that would just be strange to find in a shared link, a half-open dropdown, a hover effect, the unsaved contents of a form draft, then keep it local.

Event-driven communication

Picture two components that have no sensible shared ancestor, no shared URL state, and no shared server resource tying them together.

The relationship they have is better described not as “we both need to know this value” but as “when this thing happens over here, that thing should react over there.”

The example everyone reaches for, because it’s such a clean fit, is a toast notification system.

Almost anything, almost anywhere in your app, might need to raise a toast, and meanwhile, a single toast viewport sitting somewhere in your layout is responsible for actually displaying them.

If you tried to solve that by threading a callback from every possible sender all the way up to some common parent and then back down to the viewport, you’d be building the exact prop-drilling nightmare this whole article has been steering you away from.

So your next thought is probably the one you’ve earned by reading this far: just reach for context, or a global store.

The catch is that a toast isn’t a value at all. It’s a moment. It flares up, it gets shown, and it’s gone, and nobody ever needs to sit there reading “the current toast” the way they’d read the current theme or the logged-in user.

But context and stores are both built entirely around values that persist and get read, so the second you try to force a fleeting event into one of them, you feel the tool resisting you.

With context, you’d have to park the toast in state, push it down through the provider, and watch every consumer re-render each time one popped up.

A store gets you a little closer, and plenty of toast libraries are genuinely built on one, but you end up bending a thing designed to hold a readable state around what’s really just a queue of one-off messages the viewport drains and forgets.

What you actually want is a way to simply broadcast: to say “this happened” out loud and let whoever cares hear it, with nothing kept around afterward.

The pattern that genuinely fits this is publish-subscribe, where a publisher announces that something happened without having the faintest idea who’s listening, and subscribers react to it without knowing or caring who announced it.

And the good news is you don’t need to reach for a library to get this. The browser actually ships two event systems you can use, and even if you wanted to build the whole thing from scratch, it’s only about a dozen lines.

The most direct route uses EventTarget, which is the very same machinery that addEventListener is built on under the hood, just exposed to you to instantiate directly:

// A standalone event bus. No dependencies.
const bus = new EventTarget();

// Publish from anywhere
function notify(message, type = 'info') {
  bus.dispatchEvent(new CustomEvent('toast', { detail: { message, type } }));
}

// Subscribe from the viewport
function ToastViewport() {
  const [toasts, setToasts] = useState([]);

  useEffect(function subscribeToToasts() {
    function handleToast(event) {
      setToasts((current) => [...current, event.detail]);
    }
    bus.addEventListener('toast', handleToast);
    return () => bus.removeEventListener('toast', handleToast);
  }, []);

  // render toasts
}

The beauty of it is that notify('Saved!', 'success') can be called from a button handler, deep in your API layer, a route guard, or anywhere at all, and it reaches that viewport without a single shared prop running between them.

The detail field hanging off CustomEvent is simply how you smuggle the payload through to the other side.

And if you’d rather not lean on DOM types for this, the same idea collapses into a tiny hand-rolled registry, really just a map of event names to sets of callbacks, with emit, on, and off to drive it.

That, when you get right down to it, is all that mitt (an event-driven library) and the other micro-libraries in this space actually are, and it’s worth seeing that you could write the thing yourself in an afternoon:

function createEmitter() {
  const listeners = new Map();

  return {
    on(event, fn) {
      if (!listeners.has(event)) listeners.set(event, new Set());
      listeners.get(event).add(fn);
      return () => listeners.get(event).delete(fn); // unsubscribe
    },
    emit(event, payload) {
      listeners.get(event)?.forEach((fn) => fn(payload));
    },
  };
}

export const events = createEmitter();

Whichever of the two you go with, the defining quality is that the sender and the receiver share nothing and know nothing whatsoever about each other.

That total decoupling is amazing when you think about it, but it’s also very problematic.

When you are debugging or refactoring you can’t trace the flow by reading down the component tree, because there is no tree connection to read, so instead you end up grepping for the event name across the codebase and hoping you managed to find every last listener.

There’s also a timing trap.

An event that fires before its subscriber has mounted is simply gone, because there was no one listening when it went out.

For toasts, that’s a non-issue, since the toast that fires on a click already has a viewport mounted and ready. But for something like a “session expired” event that fires during app boot, the listener might not even exist yet, and you’ll need to either buffer those early events or arrange to fire them only once the subscriber is guaranteed to be up.

All of which is why I’d treat this as a genuine last resort.

It earns its place for the truly cross-cutting, fire-and-forget kind of signal, but the moment you catch yourself using an event bus to share a plain value that two components both need in order to render it, you’ve used it wrong, because that was lifted state or a store all along.

How to actually choose

When you boil it all down, the decision really comes back to the same two questions every time: how far apart are the two components, and what kind of value is actually moving between them.

For a long time, most apps needed only a small handful of these. You’ll use props and callbacks constantly, a bit of context for genuinely app-wide stuff like theme and the current user, and TanStack Query for anything that comes from your backend.

That combination alone will carry you a remarkably long way, then everything else is a tool you reach for when a specific problem actually calls for it.

Nobody’s dashboard has ever ground to a halt because someone dared to use props. It’s always the other direction: a global store pressed into service for local state, a context made to babysit server data, an event bus smuggling around a value that should have been a simple prop.

The reason the big tools are so seductive is precisely that they reach from anywhere, and “reaches from anywhere” turns out to be very hard to tell apart from “I never stopped to think about where this actually belongs.”

Reach for the closest tool that can actually reach the problem, and only move outward when that tool genuinely stops working.

Good luck.

Reading list

References

🏆 SOLD OUT IN SINGAPORE · ATHENS · LONDON

From Lizard to Wizard

4-hour remote system design intensive.
Chat apps, microfrontends, BFF, SDUI, event-driven, observability.

€299 4-HOUR INTENSIVE
Save your seat →

Spots are vanishing. Don't be the one who waited.

Neciu Dan

Discover more from The Neciu Dan Newsletter

A weekly column on Tech & Education, startup building and occasional hot takes.

Over 1,000 subscribers

🎙️ Latest Podcast Episodes

Dive deeper with conversations from senior engineers about scaling applications, teams, and careers.

Monorepos at Scale with Santosh Yadav
Episode 40
58 minutes

Señors @ Scale host Neciu Dan sits down with Santosh Yadav, principal developer advocate at CodeRabbit and one of only around 80 GitHub Stars in the world. Santosh started hating C in 2004, fell for C# by 2008, and turned a year of open source contributions to Angular and NgRx into a stack of community titles — Google Developer Expert, GitHub Star, Nx champion, and Microsoft MVP. As a staff engineer at Celonis he led the move of 20-plus apps to module federation and drove Nx adoption across 30-plus teams when the product grew from four apps to thirty. From the year-long incremental migration off a single deployable unit, to why polyrepos can't give AI tools the context they need, to how Nx's affected graph and build caching tame a 20-million-line monorepo, to running code review for free for open source at CodeRabbit, this is the monorepo conversation grounded in someone who actually shipped one at scale.

📖 Read Takeaways
Routing at Scale with Nicolas Beaussart-Hatchuel
Episode 39
54 minutes

Señors @ Scale host Dan Neciu sits down with Nicolas Beaussart-Hatchuel, staff engineer at Payfit and one of the maintainers of TanStack Router. Nicolas's path started with C macros to auto-generate his student paper headers and frontend learned by building phishing login pages for practice, took him through an iframe-based AngularJS-to-Angular 2 micro frontend migration at a web radio platform, into open source contributions across NX, ESLint, Vite and Hasura, and finally to maintaining one of the most ambitious routers in the React ecosystem. From why TanStack Router exists, to migrating Payfit's 300-route, 1.5-million-line codebase off React Router v5 using the strangler pattern, to collapsing 25 polyrepos and five different micro frontend strategies into a single modular monolith, this is the routing conversation most engineers never get.

📖 Read Takeaways
Redux at Scale with Mark Erikson
Episode 38
57 minutes

Señors @ Scale host Neciu Dan sits down with Mark Erikson, maintainer of Redux and senior front-end engineer at Replay.io, where he works on a time-traveling debugger. Mark's path started with a 286 he got at eight years old, ran through a computer science degree, four years teaching English in China, embedded software at Northrop Grumman emulating legacy CPUs in old aircraft, and a chain of projects — GWT, jQuery, Backbone — that led him to React and Redux. From the @deprecated backlash that had people insulting him on the internet, to why the Redux core hasn't meaningfully changed since 2016, to what RTK Query actually solves, the underused listener middleware, building source maps into React's own build pipeline, and how Replay's recordings now hand debugging over to AI agents — this is the Redux conversation grounded in two decades of shipping software.

📖 Read Takeaways
TanStack Query at Scale with Dominik Dorfmeister
Episode 37
53 minutes

Señors @ Scale host Dan Neciu sits down with Dominik Dorfmeister — better known as TkDodo — the maintainer of TanStack Query and a software engineer at Sentry. Dominik's path started at a technical high school in Vienna, ran through JVM backend work in Java and Scala, and turned to frontend around the introduction of TypeScript. During the pandemic lockdowns in Austria he started answering questions in the TanStack Discord, got addicted to the instant gratification of helping people, and slowly turned that into a blog, a first code contribution six to eight months later, and eventually maintainership of TanStack Query. From tracked queries and the chaotic version-three-to-four rename, to the version-five mistake he still dreads, to ripping 28,000 lines of dead code out of Sentry with Knip and building Sentry's new design system, this is the open source maintenance conversation most developers never get to hear.

📖 Read Takeaways
Back to Blog