DEV Community

Cover image for Leaving Ember for React: What Your Conventions Were Hiding
Emmanuel R for CobuildX AI

Posted on Originally published at cobuildx.ai

Leaving Ember for React: What Your Conventions Were Hiding

Ember's great strength was that you didn't have to decide: the router loaded your data, Ember Data cached your records, the run loop handled timing and services found their way into components. When you move to React, every one of those becomes a decision your team owns. This guide is about finding those hidden conventions and choosing what replaces each one, from Ember Data and ember-concurrency to addons, acceptance tests and the React islands that let both frameworks share a page.

One thing we always liked about Ember is that you could open someone else's project and find your way around in minutes. Ember had already decided most things for you: where data gets loaded, how records are stored, how services reach your components. That works really well while you stay. The trouble starts when you try to leave, because you realise a big part of your app's behaviour doesn't live in your code at all. It lives in Ember.

Changing Handlebars templates into JSX isn't the hard part. Most developers get comfortable with it in a day or two. What takes time is finding all the small things Ember was doing in the background, and then deciding what should do that job in React. Hyrum's Law sums it up nicely: "all observable behaviors of your system will be depended on by somebody." In other words, your app relies on Ember in ways nobody ever wrote down.

Source code: all the examples in this post come from Inkwell, a small blogging app we first built in Ember 3.24 and then rebuilt in React 18. We kept the same routes, HTML, data-test-* selectors and acceptance tests. You can find both versions at cobuild-tech/migratex-examples. Inkwell was small, so we simply rewrote it. For bigger apps, we move step by step, and that's the approach this post describes.

Before You Start

Before you start: why teams leave Ember, when staying is the better call, and the hidden conventions to inventory before sizing the migration

Why leave, and when not to

When we ask teams why they're moving away from Ember, they almost never say Ember is bad. What they say is that everything around it has become harder. New hires usually know React and have to learn Ember from scratch. There's often an old addon in package.json that nobody has touched since 2019, and it's the reason the app is stuck two versions behind. And the tools they want to use, like design systems, chart libraries and payment widgets, all come with React examples first.

That said, it's worth being honest with yourself before you commit. A modern Ember app on Octane and Embroider, looked after by people who know it well, is a perfectly good place to be. If your real problem is a handful of outdated addons, upgrading them will cost far less than a migration. Move because the ecosystem keeps slowing you down, not just because the code feels old.

Inventory the hidden conventions

Before anyone gives an estimate, sit down and list everything Ember handles for your app by convention. Each item on that list is a place where React lets you pick the approach that suits you best. In our experience, this list tells you much more about how long the migration will take than counting components ever does.

Convention What it does in Ember Where to look
The resolver Finds components, services and helpers by their file names The app/ folder layout, @service foo with no import
Route hooks Loads data before the page shows, handles redirects, loading and error screens model(), beforeModel(), loading.hbs, error.hbs
Ember Data Fetches and caches records, keeps one copy of each, links related records this.store, models, adapters, serializers
The run loop Groups updates together and schedules async work later, debounce, next, schedule('afterRender')
Initializers Runs setup code when the app starts, before the first page app/initializers, app/instance-initializers
Addons Auth, translations, forms, tasks, mock APIs package.json, often with their own rules on top

Here's a simple way to get a rough size. Search the code for @service, this.store, model(, later(, debounce( and task(, and count how many times each one shows up. It takes about ten minutes and shows you where the heavy work is.

Pay extra attention to initializers, because they're easy to overlook. Ember runs them automatically when the app starts, so they quietly read the login token, load feature flags and start analytics without anyone calling them. In React, startup code is explicit: it lives in the entry file or in a provider at the top of the app, where everyone can see it. That's a nice improvement, but it means each initializer needs to be moved on purpose. List them all early and give each one a clear home, so nothing like analytics gets left behind.

Get to Octane and Embroider first

If some parts of your app still use Classic Ember (Ember.Component, mixins, observers, or computed() with long lists of keys), upgrade them to Octane first, while they're still in Ember. It feels like extra work, but it usually saves time. Octane already works a lot like React: native classes, @tracked, clear @args and data that flows one way. That makes Octane components fairly easy to port. Classic components, with observers firing in the background, have to be fully understood before you can move them, and that's slow.

You don't have to do it all by hand. ember-native-class-codemod converts a lot of the old classes for you, and the Ember upgrade guide covers the rest. Do the same for the build and move to Embroider. It puts Ember on standard bundler tools, which makes it much easier to share packages, and later React components, between the two apps.

Running Ember and React Together

Running Ember and React together: React islands mounted inside an Ember page through a react modifier, a shared cart store read by both the Ember service and the React island, and the progression from a few islands to whole routes moving to React

When you decide to leave Ember, it's tempting to stop everything and rewrite the whole app in one go. Most people who've tried that will tell you not to. Joel Spolsky famously called Netscape's full rewrite "the single worst strategic mistake that any software company can make", and that advice has held up for more than twenty years.

The safer path is what Martin Fowler calls the strangler fig. You add new code around the old app, piece by piece, until the old app is no longer needed. Slack rebuilt its desktop client this way. They set clear rules for how old and new code could talk to each other, and the first thing they shipped was the emoji picker.

You might think the natural unit is one route at a time. In practice, Ember routes are often large and nested, and a single one can take weeks. So we start even smaller. We drop React components into Ember templates as small "islands", while Ember keeps running the rest of the page.

Mounting React with a modifier

This is simpler than it sounds. An Ember modifier is given a DOM element, so it can start a React root inside it, and clean it up again when Ember removes that element:

// app/modifiers/react.js
import { modifier } from 'ember-modifier';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';

export default modifier((element, [Component], props) => {
  const root = createRoot(element);
  root.render(createElement(Component, props));
  return () => root.unmount();
});
Enter fullscreen mode Exit fullscreen mode
<div {{react TodoList todos=@todos onComplete=this.complete}}></div>
Enter fullscreen mode Exit fullscreen mode

This short version creates a fresh React root each time an argument changes, which is perfectly fine for your first island. For busier components, switch to a class-based modifier that keeps the same root and simply calls root.render with the new props. React then updates only what changed.

Sharing state across the boundary

Once a few islands are in place, they'll need to share data with the Ember side. Say your React island needs to show the cart, but the cart lives in an Ember service. Each framework tracks changes in its own way, so the cleanest fix is a tiny store that sits outside both of them:

// shared/cartStore.js
let items = [];
const listeners = new Set();

export const cartStore = {
  get: () => items,
  set(next) { items = next; listeners.forEach(l => l()); },
  subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
};
Enter fullscreen mode Exit fullscreen mode

React has a hook made for exactly this: useSyncExternalStore. Calling useSyncExternalStore(cartStore.subscribe, cartStore.get) keeps the island in sync with the store. On the Ember side, the cart service subscribes once and copies the value into a @tracked field, so the existing templates keep working as before. When the last Ember screen that uses the cart is gone, you delete the service, and the store is already in place for React.

As more islands join up, you can start moving whole routes. One option is a small shell in front of both apps that decides which one handles each URL. Another is to let React take over routing and treat the remaining Ember screens as the islands. Either way, plan for the shared setup to be temporary. Set a rough end date for it, so the team keeps moving towards a single React app.

Replacing Ember Data

Pull the store apart

If there's one part of an Ember migration that tends to take longer than planned, it's Ember Data. A single line like this.store.findRecord('todo', 1) does a lot of work. It fetches the record, cleans up its shape, caches it, connects it to related records, and makes sure every screen sees the same copy.

React keeps data fetching separate from the view layer, so you get to choose the right tool for each of those jobs. The trick is to stop treating the store as one big thing. Break it into pieces and pick a replacement for each one:

What Ember Data does A good choice in React
Fetch and cache TanStack Query, which handles caching, refetching and keeping data fresh
Translate the API shape Your existing adapters and serializers, moved into plain, tested functions
One instance per record Refreshing the right queries after a change, or a normalised cache like RTK Query if you need it
Relationships Clear, separate requests, or letting the API return related data (include, GraphQL)
Dirty tracking and rollback Form state with React Hook Form, kept close to the form

Serializers first, identity map maybe

We always start with the serializers. They're easy to overlook, but they hold years of knowledge about your API: renamed fields, unusual date formats, and that one endpoint that sends back a different shape for reasons nobody remembers. Move them into a plain module, add a few tests, and both the Ember and React sides can use them while the migration is in progress.

Next, ask yourself whether every record really needs to be one shared object. Many teams assume it does, simply because Ember always worked that way. In most apps, refreshing the right queries after a change keeps every screen up to date, and it's easier to follow when you're debugging.

If your app relies heavily on the store, there's one more option worth a look. WarpDrive, the next version of Ember Data, is being built to work outside Ember too. Spend an afternoon checking where its React support stands before you decide.

Ember Data's identity map, where every screen points at one shared record, compared with React query invalidation, where a mutation refreshes each screen's query; plus a guide: TanStack Query for most apps, a normalised cache when records must be shared, and WarpDrive when the app is deeply invested in the store

Async Without the Run Loop

If you ask an Ember developer about race conditions, they might not have much to say. That's because the run loop and ember-concurrency have been handling timing for them for years. For example, a search box that ignores old requests only takes a few lines:

searchTask = restartableTask(async (term) => {
  await timeout(250);
  return this.api.search(term);
});
Enter fullscreen mode Exit fullscreen mode

Here, restartableTask cancels the previous search when a new one starts, timeout adds a short delay while the user types, and the task stops on its own if the user leaves the page.

In React, you get the same result by choosing the right tool for each kind of async work. For anything that fetches data, TanStack Query does this job really well. You use the search term as the query key, and it makes sure only the latest result is shown. It even gives you an AbortSignal to pass to fetch, so old requests can be cancelled:

const debounced = useDebouncedValue(term, 250);
const { data } = useQuery({
  queryKey: ['search', debounced],
  queryFn: ({ signal }) => api.search(debounced, { signal }),
  enabled: debounced.length > 0,
});
Enter fullscreen mode Exit fullscreen mode

For things like polling, timers and animations, React keeps the setup and the cleanup together in the same effect, so it's easy to see what starts and what stops. The React team's guide You Might Not Need an Effect is a great read before you begin.

Then go through your ember-concurrency tasks one by one and think about what each one was really for. A restartable task usually becomes a query with the right key. A drop task becomes a button that's disabled while the request is running. An enqueue task becomes a small queue. And here's a nice surprise: many of those later() and schedule('afterRender') calls were only there to fix Ember timing issues. In React you often don't need them at all, so you can simply delete them.

Addons and Tests

Replacing the addons

It helps to think of each addon as its own small migration. The good news is that React has a strong library for almost every one of them, and in a few cases you can bring your existing work straight across:

Ember addon React option What you can keep
ember-intl react-intl (FormatJS) Your translation files, since both use the same ICU message format
ember-cli-mirage MSW (Mock Service Worker) Your fixtures; the handlers are written once and shared by both apps
ember-simple-auth Your auth provider's SDK, or a small auth context The session storage format, as long as you keep it the same
ember-concurrency TanStack Query plus effects Your cancellation rules, once they're written down
ember-power-select Downshift, React Select or your design system A fresh start; set aside some time for a design review
ember-page-title <title> in components (React 19) Your page title text

We'd suggest moving translations and mocks early, before most of the components. If both apps read from one shared set of ICU translation files, users see the same wording on every screen from day one. And once your mocks are in MSW, the same fake API works in React tests, in Ember tests and on your laptop.

Give authentication a little extra care. If both apps read the same session cookie or storage key, in the same format, users can move between Ember and React screens without ever noticing. That smooth hand-off is what makes a step-by-step migration feel invisible to your users.

Acceptance tests are the contract

One of the most valuable things an Ember team has is its acceptance tests. Years of them describe, in detail, what users can actually do. Treat them as the checklist the React version needs to pass.

The tests will look a little different on the React side. Ember tests usually call await settled(), which waits for the run loop, network requests and timers to finish. React Testing Library takes a more user-focused approach. It waits for what the user would actually see on screen, using findBy... queries. Here's the same test in both:

// Ember
await visit('/todos');
await click('[data-test-add]');
assert.dom('[data-test-todo]').exists({ count: 1 });

// React Testing Library
render(<Todos />);
await user.click(screen.getByRole('button', { name: /add/i }));
expect(await screen.findAllByRole('listitem')).toHaveLength(1);
Enter fullscreen mode Exit fullscreen mode

Here's what has worked well for us. Pick the user journeys that matter most and write them as Playwright tests. Get them passing against the Ember app first, then run the exact same tests against React. When a journey passes on both, you know it's migrated. If it only passes on Ember, it still needs a bit more work, however finished the code looks. Keep your data-test-* attributes as they are, so both apps share the same test handles.

One Playwright test suite with shared data-test handles runs against the Ember app first, then the React app; a journey counts as migrated only when it passes on both, so one that passes only on Ember is not done yet

Finishing the Migration

Helping the team settle in

Code moves faster than habits, and that's completely normal. When Ember developers write their first React pull requests, we usually see the same three patterns. Each one has a simple React-friendly alternative:

  • Turning every service into a context. It's a natural first step, but most services don't need to be global. A plain module of functions, or state kept close to the components that use it, is often simpler.
  • Using effects the way observers were used. In React, a value that depends on other values can usually be calculated right during render, or updated in the event handler that changed it. The React docs on effects explain this really well.
  • Fetching data inside effects. People miss Ember's model() hook, and React has great options that feel just as familiar: router loaders or a query library like TanStack Query.

A little support goes a long way here. A short internal guide that shows your app's common patterns in both Ember and React pays for itself within weeks. Pairing an Ember expert with a React expert on code reviews helps even more, and both of them usually learn something new.

The removal checklist

You'll know the migration is finished on the day ember-source, ember-data and ember-cli are removed from package.json. Before you make that commit, run through a few quick checks:

  • Every route, including error, loading and "not found" pages, is now served by React.
  • Every initializer has a new home in React, or has been removed on purpose.
  • Session, feature flags and analytics all start from the React entry file.
  • The shared store, the island modifier and any other bridge code have been cleaned up.
  • Old URLs still work, with redirects in place for anything that changed.

The removal checklist: no route served by Ember, every initializer has a home, boot code starts in React, bridge code is gone, old URLs still resolve; then ember-source, ember-data and ember-cli are removed from package.json, with the last Ember build kept behind a flag until traffic stays quiet

Even after that, keep the last Ember build ready to deploy behind a flag for a short while. Real users have a way of finding the one thing nobody tested. TSB Bank is a well-known example. In 2018, a platform migration disrupted service for a large share of its 5.2 million customers, and UK regulators later fined the bank £48.65m. A front-end migration is much lower risk, but the lesson still applies: keep a way back until you're sure you won't need it. Once things have been quiet for long enough, delete it and enjoy the smaller bundle.

Looking back, moving from Ember to React is mostly about making clear choices for the things Ember used to handle behind the scenes: loading data, caching, async timing and startup code. Your team now decides how each of these works, which means a bit more effort at the start and a cleaner, easier-to-follow app at the end.

If we had to sum it up in a few lines: list the conventions before you estimate, get to Octane before you port, start with small islands, and give the data layer, addons and tests the same care as the components. The JSX part will take care of itself.

About MigrateX

MigrateX

MigrateX is our software and services platform for moving software, data and infrastructure from one technology to another. It brings automation and experienced engineers together, so the repetitive work gets done quickly and the tricky decisions get the human attention they need.

Everything in this guide comes from how we run Ember to React projects with MigrateX. Here's what that looks like in practice:

  1. Assessment. We scan your Ember app and build the inventory described above: services, store usage, route hooks, run loop calls, initializers and addons. You get a clear picture of where the effort really sits before anyone commits to a timeline.
  2. Migration plan. Together with your team, we decide what each convention becomes in React, which screens move first, and how Ember and React will share state, sessions and translations along the way.
  3. Step-by-step migration. Automation handles the repetitive parts, like converting templates, porting serializers and setting up MSW mocks. Our engineers handle the parts that need judgement, like data layer design, async behaviour and the islands that bridge the two apps.
  4. Proof that it works. Your most important user journeys run as Playwright tests against both apps. A screen only counts as migrated when it passes on both, so progress is something you can see, not just something you're told.
  5. Handover and clean-up. We remove the bridge code and the last Ember packages, keep a way back until traffic is quiet, and leave your team with a React codebase they know and own.

What you get along the way:

  • No big-bang release. Your app keeps running and shipping features while the migration happens in the background.
  • Less risk. Shared tests, a shared session and a way back mean users don't notice the move, and you can pause at any point.
  • A team that's ready. We pair with your developers throughout, so they're comfortable with React long before the last Ember screen is gone.
  • More than front ends. The same approach covers other frameworks, like Angular and Backbone, as well as data and infrastructure migrations.

To see the code behind this guide, visit github.com/cobuild-tech/migratex-examples. Planning a move from Ember, or already partway through? We'd love to hear about it. Tell us a little about your app through Contact Us, and we'll start with a free conversation about where the effort is likely to be.

References

Source code

Migration strategy and case studies

Ember

React and testing

  • React. useSyncExternalStore. https://react.dev/reference/react/useSyncExternalStore
  • React. You Might Not Need an Effect. https://react.dev/learn/you-might-not-need-an-effect
  • React. .</em> <a href="https://react.dev/reference/react-dom/components/title">https://react.dev/reference/react-dom/components/title</a></li> <li>React Router. <em>Data loading.</em> <a href="https://reactrouter.com/">https://reactrouter.com/</a></li> <li>Testing Library. <em>Async methods</em> (<code>findBy</code> queries). <a href="https://testing-library.com/docs/dom-testing-library/api-async/">https://testing-library.com/docs/dom-testing-library/api-async/</a></li> <li>Playwright. <a href="https://playwright.dev/">https://playwright.dev/</a></li> </ul> <p><strong>Libraries</strong></p> <ul> <li>TanStack Query. <a href="https://tanstack.com/query/latest">https://tanstack.com/query/latest</a></li> <li>React Hook Form. <a href="https://react-hook-form.com/">https://react-hook-form.com/</a></li> <li>FormatJS. <em>react-intl.</em> <a href="https://formatjs.github.io/docs/react-intl/">https://formatjs.github.io/docs/react-intl/</a></li> <li>MSW (Mock Service Worker). <a href="https://mswjs.io/">https://mswjs.io/</a></li> </ul> <hr> <p><em>Originally published on the <a href="https://cobuildx.ai/blog/ember-to-react-migration-guide">CobuildX AI blog</a>.</em></p>

Top comments (0)