Photo by Karl Bewick on Unsplash
In this post I'm proposing some improvements for Ember in an important, but often overlooked use case: embedding Ember components in non-Ember applications. Ember is great for brand new web applications. But what story do we tell for existing apps that are looking to transition to Ember?
Consider a single page application that started in 2016 that uses React and webpack. There's already support for pulling in ES modules and rolling them into the production bundle. However, the team has heard about the many tooling improvements to Ember and wants to experiment shipping a small component in this existing React app. However, because the app uses a client-side router, there needs to be a mechanism to load the Ember app and render into a div without resorting to an iframe.
Teams might choose not to adopt Ember because they can't afford a several month feature freeze to port components over. This post aims to solve these pain points so that teams are free to progressively ship Ember components in their apps and migrate the application over time.
Ember applications are built and packaged with the ember-cli
tooling. Because the CLI tooling and the framework are deeply integrated, addons can be developed that make modifications to the build process. A few great examples of this are adding type checking with ember-cli-typescript
, generating thin wrappers for ES modules with ember-auto-import
, or even transforming imports from module syntax import { computed } from '@ember/object'
to Ember.computed
. One drawback of this tooling, however, is the artifacts it emits are not ideal for embedded scenarios.
Let's consider what embedding an Ember component in a React app could look like:
React component
function ListUsers() {
const users = [{ id: 1, name: 'masters' }];
return <UsersWrapper users={users} />;
}
Ember component, invoked from React
<div class="some-app__users">
{{#each @users as |user|}}
<div class="some-app__users__id">ID: {{user.id}}</div>
<div class="some-app__users__id">Name: {{user.name}}</div>
{{/each}}
</div>
There isn't currently a way to mix Ember components in existing React applications like this. However, if we introduce a simple wrapper component:
import React, { useEffect, useRef } from 'react';
import TinyEmber from 'tiny-ember-app';
import Users from './ember-components/users';
function UsersWrapper(props) {
const containerRef = useRef();
// this is invoked when the host component mounts
useEffect(() => {
const mountedInstance = TinyEmber.mount(Users, containerRef.current, props);
// this is called when the host component unmounts
return () => mountedInstance.destroy();
}, []);
return <div ref={containerRef} />;
}
TinyEmber in this example is a fake library that provides a thin API over Ember itself. The mount
method takes in a reference to the component to be rendered (which it will handle initializing), a DOM node, and a set of initial component arguments. This is very similar to the design of ReactDOM.render
, but also accepts initial component arguments for the root Ember component. Note that these initial component arguments will only be used for the first render of the Ember component - synchronizing state updates between the parent app and the Ember app left as an exercise for the reader (message passing could be used in this case).
The consuming app (React in this case) has a different component model, but it can still seamlessly mount and unmount Ember components and pass data to them. As the new app grows in size, "legacy" components can coexist with newer Ember components, and older components ported over one at a time. Optionally in the future, these old components can be removed entirely, and the transition to fully Ember components is complete.
Embedding Ember applications is already documented, but the current approach has a few limitations.
Hard-coded selector for root node
The selector for the containing element div is specified at build-time, and the emitted output from ember build
contains statements to initialize the app and render it on the page as soon as the bundle finishes parsing. The current initialization code would need to be handled by the consumer so that the component could be initialized and destroyed when the parent component is unmounting, potentially multiple times within the lifetime of the parent app.
Missing API to mount / unmount Ember components
There isn't currently a thin API to render Ember or Glimmer components by themselves. It looks like some support for this lives in the GlimmerJS repo, and a new standalone wrapper could probably be written to hack this together. I'd love it if there was a first-class API for this though. There also doesn't seem to be a concept of initializing the root Ember component with initial arguments at runtime.
Building Ember components for external use
Components also would need to exported in a way where they can be referenced by an external bundler. For example, ember build
could emit a library Javascript bundle containing just the Ember components, and something like webpack-node-externals to reference the @ember
vendor imports. That way if lodash
was included in an Ember component and in the host application, the vendor bundle would only include one copy. Webpack has excellent support for emitting bundles that are compatible with CommonJS import syntax, so maybe some of this support could be leveraged in Embroider. If this wasn't possible in the near term, exposing the root component as window.EmberComponents.MyRootComponentNameHere
could work in the interim before the bundler changes land.
Components without the services
The current Ember architecture may not work well with an environment that needs to unmount the root component via a single page app route transition, as the javascript context would not be reloaded between virtual page transitions. If addons or services make assumptions about only being initialized once, this could be problematic. For this reason, we should focus on just supporting rendering Ember components without many of the monolith services that could be injected (such as ember-data and the router). After the story for rendering components is stable, support for these more complicated services could be added.
Prior art
After writing this article, I discovered that react-svelte exists already! You can check out the component implementation here, which has support for mounting, updating, and destroying the underlying Svelte component.
Thanks for reading!
By exposing an API to render components from other JS frameworks, Ember provides a better migration story for existing single page applications. Although React was used as an example, the same concepts apply to an existing app written with jQuery, or any other front-end framework for that matter. Many of the pieces are already in place to support this API (such as explicit this.args
for Glimmer components and removing the dependency on jQuery). I look forward to seeing what progress we can make towards that goal this year!
Thanks to Frank Tan, Shane Warren, and Chris Krycho for reviewing earlier drafts.
Top comments (3)
I think this'd be more possible once the community fully migrates over to glimmer components from ember components. I could totally see glimmer components used in React environments without too much effort. Not sure they'd interact performance-wise though. Like, React components like to re-render a lot (nature of JSX)
Yeah. In my example re renders and props updates are totally ignored, but if props is passed to the useEffect array, the Ember component would get destroyed and recreated each time props change. Need a solid βupdateβ API for this to be a good DX, havenβt thought through what could look like yet.
That's a really interesting thought. I think the Ember community could use a lot of the knowledge from the Glimmer.js project to make use cases like the one you described possible.
As I mentioned in my blog post, we as the Ember community shouldn't forget about Glimmer.js. Would be great to integrate Glimmer.js better into the Ember eco-system. I think this would widen the possible use cases of Ember.