WebSockets provide a way for client apps to receive live updates from the server as data is changed by other users, or by the same user in a different tab or on a different device. WebSockets are a low-level technology, but by building on top of high-level abstractions like JSON:API we can get started adding live updates to our app fairly quickly.
Let’s take a look at adding live updates to an Ember app that uses Ember Data. For this blog post we’ll use a Rails API with Action Cable for the WebSocket communication. In the example repo you can also find an example with Express and standard WebSockets. Similar patterns would apply with other backends, such as Golang WebSockets or Phoenix channels.
You’ll need the following installed:
Setting Up the Backend
Create a new Rails app in API mode with a Postgres database:
$ rails new --api --database=postgresql live_updates_api
$ cd live_updates_api
Next, we need to configure the Postgres database to use UUID fields. This will make it easier to keep the frontend in sync. Generate a migration file:
$ rails g migration enable_uuids
Open the migration file and enable the following two extensions:
class EnableUuids < ActiveRecord::Migration[6.0]
def change
enable_extension 'uuid-ossp'
enable_extension 'pgcrypto'
end
end
Create the database and run the migration:
$ rails db:create
$ rails db:migrate
Now, generate a Todo model:
$ rails g model todo name:string
Before running the migration, update the migration file to use UUIDs for the ID field:
class CreateTodos < ActiveRecord::Migration[6.0]
def change
- create_table :todos do |t|
+ create_table :todos, id: :uuid do |t|
t.string :name
Now run the migration:
$ rails db:migrate
Add the JSONAPI::Resources gem to your Gemfile:
gem 'jsonapi-resources'
And install it:
$ bundle install
Generate a Resource class for the todo:
$ rails g jsonapi:resource todo
Open app/resources/todo.rb
and configure it to expose the name
attribute:
class TodoResource < JSONAPI::Resource
attribute :name
end
Generate the controller for the todos:
$ rails g jsonapi:controller todos
Configure the controller to not require an authenticity token, since that is more difficult to do with single-page apps like our Ember app:
class TodosController < JSONAPI::ResourceController
skip_before_action :verify_authenticity_token
end
Then add routes for that controller in app/config/routes.rb
:
Rails.application.routes.draw do
jsonapi_resources :todos
end
Now we need to enable CORS so our Ember frontend on another port can access the API. In the Gemfile, uncomment:
gem 'rack-cors'
Then run:
$ bundle install
Open config/initializers/cors.rb
and uncomment the following block:
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'example.com'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
Then change the origins
line to allow all origins:
allow do
- origins 'example.com'
+ origins '*'
resource '*',
Now the backend should be ready to go!
Setting Up the Frontend
Create a new Ember app:
$ ember new --no-welcome live-updates-frontend
$ cd live-updates-frontend
Generate an Ember Data adapter to configure our connection to the server:
$ ember g adapter application
Open app/adapters/application.js
and replace the contents with the following:
import JSONAPIAdapter from '@ember-data/adapter/json-api';
export default class ApplicationAdapter extends JSONAPIAdapter {
host = 'http://localhost:3000';
}
Generate a todo model:
$ ember g model todo
Open the generated app/models/todo.js
and add the name
attribute to it:
-import Model from '@ember-data/model';
+import Model, { attr } from '@ember-data/model';
export default class TodoModel extends Model {
+ @attr name;
}
Generate a TodoList
component:
$ ember g component TodoList
Open the generated app/components/todo-list.hbs
and add the following code:
Now generate a NewTodoForm
component and class:
$ ember g component NewTodoForm
$ ember g component-class NewTodoForm
Add the following to app/components/new-todo-form.hbs
:
And replace the contents of app/components/new-todo-form.js
with this:
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class NewTodoFormComponent extends Component {
@service store;
@tracked name;
@action async add(evt) {
evt.preventDefault();
const todo = this.store.createRecord('todo', { name: this.name });
try {
await todo.save();
this.name = '';
} catch (err) {
console.error(err);
}
}
}
Generate an index route to put these components on:
$ ember g route index
Then open app/routes/index.js
and load the Todo models:
import Route from '@ember/routing/route';
export default class IndexRoute extends Route {
model() {
return this.store.findAll('todo');
}
}
In app/templates/index.js
, create an instance of each component:
Trying It Out
With that, we should be ready to run our app!
In one tab, start the Rails API:
$ rails s
And in another tab, start the Ember frontend:
$ ember s
Visit the Ember app in a browser at http://localhost:4200
. You should see the New Todo form. Add a few todos and confirm that they show up in the list below.
Now, let’s see the limitation we have, and how live updates can help us. Open the Ember app in a second tab and add a todo to it. Note how the todo list in the first list is not updated. Reload the first tab and see that the new todo shows up.
For applications where we want live updating, how can we accomplish that?
Adding Live Updates with Action Cable
First, we generate a TodosChannel
that will handle live update communication for todos:
$ rails g channel todos
Open app/channels/todos_channel.rb
. In The TodosChannel#subscribed
method, uncomment the line with #stream_from
and give the channel a name:
class TodosChannel < ApplicationCable::Channel
def subscribed
- # stream_from "some_channel"
+ stream_from "todos"
end
Next, when we create a todo, we need to configure the app to send that todo over the channel. Where can we do that? We could put it in a model #after_create
callback, but that would mean it would attempt to run any time we create a model, even in a seeder or the console.
Instead, let’s add this logic to an #after_create
callback in the TodoResource
class. We only use the resources through our web interface, so this should only run when we make a web request.
In app/resources/todo_resource.rb
, add the following:
class TodoResource < JSONAPI::Resource
+ after_create :notify_clients
+
attribute :name
+
+ private
+
+ def notify_clients
+ ActionCable.server.broadcast 'todos', serialized_model
+ end
+
+ def serialized_model
+ serializer = JSONAPI::ResourceSerializer.new(TodoResource)
+ serializer.object_hash(self, nil)
+ end
end
Here’s what’s going on:
- In the class body, we call the
#after_create
macro to set up a callback, passing the:notify_clients
symbol to indicate that a#notify_clients
method should be called - We define
#notify_clients
as a private method. In it, we callActionCable.server.broadcast
to send the message to the'todos'
channel. The data we pass is the return value of the#serialized_model
method - We define
#serialized_model
to instantiate a serializer for theTodoResource
and then serialize the current resource
This is all we need to do in the API; let’s move over to the frontend.
Action Cable has an npm
package for the client JavaScript code; let’s install it:
$ npm install --save-dev actioncable
Now, we need somewhere in our code to set up the Action Cable connection. An ApplicationController
will work well enough for this sample project. Generate it:
$ ember g controller application
Open app/controllers/application.js
and see the following contents:
import Controller from '@ember/controller';
export default class ApplicationController extends Controller {
}
Now let’s set up Action Cable one step at a time. First, let’s add a constructor
that we can add additional functionality to:
export default class ApplicationController extends Controller {
+ constructor(...args) {
+ super(...args);
+ }
}
Our constructor calls the parent class constructor.
Next, let’s create an Action Cable connection:
import Controller from '@ember/controller';
+import ActionCable from 'actioncable';
export default class ApplicationController extends Controller {
constructor(...args) {
super(...args);
+
+ this.cable = ActionCable.createConsumer(
+ 'ws://localhost:3000/cable'
+ );
}
}
We connect to our localhost:3000
server over the WebSocket protocol (ws
), passing the default Action Cable path of /cable
.
Next, we need to subscribe to the TodosChannel
specifically:
constructor(...args) {
super(...args);
this.cable = ActionCable.createConsumer(
'ws://localhost:3000/cable'
);
+ this.cable.subscriptions.create('TodosChannel', {
+ connected: () => {
+ console.log('connected');
+ },
+
+ disconnected: () => {
+ console.log('disconnected');
+ },
+
+ received: todo => {
+ console.log('received', todo);
+ }
+ });
}
We go ahead and add some logging to confirm when Action Cable connects and disconnects.
Reload the Ember application and you should see “connected” in the web developer console, showing that we’ve connected. Make sure you have the app open in two tabs, then add a todo. Note that in both tabs we get a log entry that says “received”, and then a JSON:API-formatted data record.
So we’re getting the created record pushed to the frontend–what can we do with it? The Ember Data store has a .pushPayload()
method that will add this record into the store. To use it, inject the store service into the controller, then call the method:
import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
import ActionCable from 'actioncable';
export default class ApplicationController extends Controller {
+ @service store;
constructor(...args) {
...
received: todo => {
console.log('received', todo);
+ this.store.pushPayload({ data: todo });
}
After the Ember app reloads, add another todo. You will see it automatically appear in the list in the second tab!
In the first tab, though, there’s a problem: the same todo appears twice! Why is that? The problem is a timing issue:
- We send the request to the API to create the new todo.
- Action Cable sends the new todo to all connected clients.
- The client sees the new todo and pushes it into the store.
- THEN, the web service call returns a response with the new todo. Ember Data doesn’t suspect we might have added that todo into the store ourselves already, so it dutifully adds it into the store again, resulting in a duplicate record.
There are a few different ways we can solve this. The approach we’ll take is to assign UUIDs in the frontend, rather than relying on the backend to do it. That way, as soon as the record is created, we can uniquely identify it. Whether we are adding it into the store from Action Cable or a create response, Ember Data will know it is the same record and will overwrite it instead of duplicating it.
To set up the frontend to create UUIDs, first, add the uuid
package as a dependency:
$ npm install --save-dev uuid
Next, configure the ApplicationAdapter
to assign a UUID as the default ID for a record:
import JSONAPIAdapter from '@ember-data/adapter/json-api';
+import { v4 } from 'uuid';
export default class ApplicationAdapter extends JSONAPIAdapter {
host = 'http://localhost:3000';
+
+ generateIdForRecord() {
+ return v4();
+ }
}
We’ll need to make a change to the backend, as well, to allow the ID to be passed in. Add the following class method to TodoResource
:
def self.creatable_fields(context)
super + %i[id]
end
This adds id
as one of the fields allowed to be created.
Try adding a new todo again. You’ll see that only one instance of the todo appears in the first tab, and it still automatically shows up in the second tab as well.
Conclusion
Live updates are a new way of thinking about data flow in your app, but the libraries we used allowed us to add them without too much code. We were able to get live updates working with the help of the ActionCable.server.broadcast
method on the server and Ember Data’s pushPayload()
method on the client. We had an issue with duplicate records, and we solved it with UUIDs here—but other approaches would be possible if you want or need to do something different. For a more complex app you would need to add more logic to handle live updating of different model types, related records, and user-specific data. But this approach should get you a good start!