Skip to main content

Turbo Streams

Turbo Streams deliver page changes as <turbo-stream> HTML fragments. Each fragment specifies an action (what to do) and a target (which DOM element to act on). The browser's Turbo library processes these fragments and updates the page without a full reload.

The turboStream Builder

The core function builds a single <turbo-stream> element:

import 'package:routed_hotwire/routed_hotwire.dart';

final html = turboStream(
action: TurboStreamAction.append,
target: 'messages',
html: '<li>New message</li>',
);
// => <turbo-stream action="append" target="messages"><template><li>New message</li></template></turbo-stream>

Parameters

ParameterTypeDescription
actionTurboStreamAction (required)Which DOM mutation to perform
targetString?A single DOM element ID
targetsString?A CSS selector matching multiple elements
htmlString?The inner HTML content
attributesMap<String, String>?Extra attributes on the <turbo-stream> element

Content is automatically wrapped in a <template> tag for all actions except remove and refresh, which don't need inner content.

Actions

The TurboStreamAction enum covers all 8 standard Turbo Stream actions:

ActionDescriptionRequires HTML?
appendAppend content inside the target elementYes
prependPrepend content inside the target elementYes
replaceReplace the entire target elementYes
updateReplace the target's inner contentYes
removeRemove the target element from the DOMNo
beforeInsert content before the target elementYes
afterInsert content after the target elementYes
refreshTrigger a Turbo page refreshNo

Convenience Builders

Each action has a dedicated function so you don't need to pass the action parameter:

// Append a new row to a table body
turboStreamAppend(
target: 'user-list',
html: '<tr><td>Alice</td><td>alice@example.com</td></tr>',
);

// Prepend a notification
turboStreamPrepend(
target: 'notifications',
html: '<div class="alert">New notification</div>',
);

// Replace an entire element
turboStreamReplace(
target: 'user-42',
html: '<div id="user-42">Updated content</div>',
);

// Update only the inner content
turboStreamUpdate(
target: 'counter',
html: '42',
);

// Remove an element
turboStreamRemove(target: 'old-item');

// Insert before a target
turboStreamBefore(
target: 'footer',
html: '<div>Inserted before footer</div>',
);

// Insert after a target
turboStreamAfter(
target: 'header',
html: '<div>Inserted after header</div>',
);

// Trigger a page refresh
turboStreamRefresh(requestId: 'abc-123');

Function Signatures

All content-bearing functions require target and html as named parameters. turboStreamRemove requires only target. turboStreamRefresh takes an optional requestId. All accept optional attributes.

Combining Multiple Fragments

A single response can contain multiple stream fragments. Use joinTurboStreams to concatenate them:

final payload = joinTurboStreams([
turboStreamAppend(target: 'messages', html: '<li>Hello</li>'),
turboStreamUpdate(target: 'unread-count', html: '3'),
turboStreamRemove(target: 'empty-state'),
]);

// Send as a Turbo Stream response
return ctx.turboStream(payload);

Targeting Multiple Elements

Use the targets parameter with a CSS selector to act on multiple elements at once:

turboStream(
action: TurboStreamAction.update,
targets: '.price-display',
html: '\$9.99',
);
// => <turbo-stream action="update" targets=".price-display"><template>$9.99</template></turbo-stream>

Custom Attributes

Pass extra attributes to the <turbo-stream> element via the attributes parameter:

turboStreamAppend(
target: 'feed',
html: '<article>New post</article>',
attributes: {'method': 'morph'},
);
// => <turbo-stream action="append" target="feed" method="morph"><template><article>New post</article></template></turbo-stream>

Attribute values are automatically HTML-escaped.

Body Normalization

normalizeTurboStreamBody coerces different input types into a single string for response bodies:

// String passes through
normalizeTurboStreamBody('<turbo-stream ...>');

// Iterable is joined
normalizeTurboStreamBody([fragment1, fragment2]);

// Anything else throws ArgumentError

This is used internally by TurboResponse.stream() and ctx.turboStream(), so you typically don't need to call it directly.