In our previous post, we talked about how to improve an app’s performance and user experience by incrementally updating our app’s UI as we received a stream of data from our API. Our example app was built on the Fetch API and can-ndjson-stream to get a ReadableStream of NDJSON and render the stream in our app.
If you’re using can-connect, there’s an even easier way to render a stream of data in your app—with can-connect-ndjson! This post will demonstrate how to configure this behavior to incrementally load an API response that’s streamed by your server.
Getting started with can-connect-ndjson
can-connect-ndjson is a can-connect behavior that can receive, transform, and render lines of a server response body as a stream on the client.
If you are familiar with can-connect, then you have probably used behaviors in the past to connect your model with the HTTP layer. If you’re not familiar, it looks something like this:
const connect = require("can-connect");
const DefineList = require("can-define/list/list");
const DefineMap = require("can-define/map/map");
//Require behaviors for connection
const urlBehavior = require("can-connect/data/url/url");
const constructorBehavior = require("can-connect/constructor/constructor");
const mapBehavior = require("can-connect/can/map/map");
const behaviors = [urlBehavior, constructorBehavior, mapBehavior];
// Define model
const Todo = DefineMap.extend("Todo", {id: "number", name: "string"});
Todo.List = DefineList.extend("TodoList", {"#": Todo});
// Create connection passing behaviors and options
Todo.connection = connect(behaviors, {
Map: Todo,
List: Todo.List,
url: "/todos"
});
//GET request for a list of todos from "/todos"
const todosPromise = Todo.getList({});
Add the can-connect-ndjson behavior to support streamable responses
can-connect-ndjson works by extending the Data and Instance interfaces to work with streamed NDJSON data to create instances of the data model. Simply require the behavior and pass the optional NDJSON endpoint if your backend serves NDJSON from an endpoint other than your default url
endpoint.
Steps:
- Require the
can-connect-ndjson
behavior - Add the
can-connect-ndjson
behavior to thebehaviors
array - Pass the behaviors into the connection
- [Optional] Pass the NDJSON endpoint if it differs from your default
url
The todosPromise
will resolve with an empty list once a connection is established, then todosPromise.value
will be updated with the first Todo
instance once the first line of NDJSON is received. Each todo
instance will be a line of the NDJSON.
const connect = require("can-connect");
const DefineList = require("can-define/list/list");
const DefineMap = require("can-define/map/map");
//Require behaviors for connection
const urlBehavior = require("can-connect/data/url/url");
const constructorBehavior = require("can-connect/constructor/constructor");
const mapBehavior = require("can-connect/can/map/map");
//Step 1: Require the NDJSON behavior.
const ndjsonBehavior = require("can-connect-ndjson");
//Step 2: Add can-connect-ndjson (ndjsonBehavior) to the behaviors array.
const behaviors = [urlBehavior, constructorBehavior, mapBehavior, ndjsonBehavior];
// Define model
const Todo = DefineMap.extend("Todo", {id: "number", name: "string"});
Todo.List = DefineList.extend("TodoList", {"#": Todo});
//Step 3: Create the connection by passing behaviors and options
Todo.connection = connect(behaviors, {
Map: Todo,
List: Todo.List,
url: "/todos",
ndjson: "ndjson/todos" //Step 4: [optional] specify the NDJSON API endpoint
});
//GET request for NDJSON stream of todos from "/ndjson/todos"
const todosPromise = Todo.getList({});
There you have it! Let’s render it incrementally.
You have now configured your can-connect
connection to receive stream responses from your API and create instances of the data model. Now use the model with a template:
const stache = require("can-stache");
const template = "<ul>{{#each todosPromise.value}}<li>{{name}}</li>{{/each}}</ul>";
const render = stache(template);
document.body.append(render({todosPromise: todosPromise}));
Remember: once a connection is established, todosPromise.value
will be an empty array until the first line of NDJSON data is received, then the NDJSON lines will be deserialized into Todo
instances and pushed into your array.
Conditional rendering based on state
In a real-world environment, we not only need to render the state of the List
model, but also the state of the stream so that we can communicate to our users whether or not to expect more data or if there was an error. To do this, we have access to the following state properties:
Promise state, the state of the initial connection to the stream:
isPending
—the list is not available yetisRejected
—an error prevented the final list from being determinedisResolved
—the list is now available; note that the list is still empty at this point
Streaming state, available on the list after the promise is resolved to a stream:
isStreaming
—the stream is still emitting datastreamError
—an error that has prevented the stream from completing
Here is an example of a template capturing the various states and rendering conditionally for an improved user experience. In this example, we still pass todosPromise
to render our template:
{{#if todosPromise.isPending}}
Connecting
{{/if}}
{{#if todosPromise.isRejected}}
{{todosPromise.reason.message}}
{{/if}}
{{#if todosPromise.isResolved}}
<ul>
{{#each todosPromise.value}}
<li>{{name}}</li>
{{/each}}
</ul>
{{#if todosPromise.value.isStreaming}}
Loading more tasks
{{else}}
{{#if todosPromise.value.streamError}}
Error: {{todosPromise.value.streamError}}
{{else}}
{{^todosPromise.value.length}}
<li>No tasks</li>
{{/todosPromise.value.length}}
{{/if}}
{{/if}}
{{/if}}
Next steps
Find more details about using can-connect with NDJSON streams in the can-connect-ndjson docs.
If you use this new module, let us know on our forums or Gitter chat! We’d love to hear about your experience using NDJSON streams with can-connect.
We’re working on even more streamable app features for DoneJS. Keep up with the latest in the community by following us on Twitter!