Dock Layouts: Panes Are Ordinary Components
A work queue is useful because it remembers the work. You mark a task complete, move the queue beside your editor, and keep going. Collapsing it into a side rail should not reset its records. Closing its view should not silently throw away application data that another view still needs.
Those expectations sound like docking requirements, but they begin with ordinary component ownership. The layout decides where a pane belongs. Your application decides what the pane displays, who owns its data, and how long that data should live. Keeping those decisions separate lets you add docking to a useful component without teaching that component about tab groups, rails or saved layouts.
This guide builds a small work queue and follows it through those changes. The adoption guide covers application bootstrap and initial layout declarations. Here, the question is what belongs inside a pane, and what should outlive it.
Start with the owners
Our example has a queue pane and a summary pane. The queue contains a button and a grid. Its controller completes the next task; the grid observes the changed record. The summary displays the last completed task through a state binding.
Both views consume state owned by a provider at their common workspace root. That provider creates the queue Store. The Store uses a Model to define its records. Neither view needs a private copy of the queue, and neither needs to tell the other to repaint.
The placement document is deliberately absent from the action path. Completing a task changes a record and shared application state. Moving the queue changes placement. There is no reason for either operation to serialize the other one's objects.
Give the queue a real data model
The three records are small enough that an array would be tempting. Using a Store here is about ownership and observation, rather than volume: the grid consumes records, and an application action updates the same records through the Store. This remains the same composition when you replace the initial data with a loading pipeline.
The following modules share an application directory. Their engine imports assume a location three levels below the repository root, as in the dashboard examples; adjust the paths for your application.
These five file-by-file examples stay readonly because each fence represents one application module.
The complete live preview later in this guide folds the same roles into the portal's single-module execution
envelope, so you can run the ownership journey without confusing that teaching constraint with an app structure.
QueueModel.mjs defines the record fields:
import Model from '../../../src/data/Model.mjs';
class QueueModel extends Model {
static config = {
className: 'WorkQueue.model.Task',
fields: [
{name: 'id', type: 'String'},
{name: 'title', type: 'String'},
{name: 'status', type: 'String'}
]
}
}
export default Neo.setupClass(QueueModel);QueueStore.mjs supplies the data:
import QueueModel from './QueueModel.mjs';
import Store from '../../../src/data/Store.mjs';
class QueueStore extends Store {
static config = {
className: 'WorkQueue.store.Tasks',
model: QueueModel,
data: [
{id: 'design', title: 'Review the design', status: 'Ready'},
{id: 'build', title: 'Build the preview', status: 'Ready'},
{id: 'check', title: 'Check the result', status: 'Ready'}
]
}
}
export default Neo.setupClass(QueueStore);The Model declares the schema; the Store exposes the records. A docking item named queue will eventually
display these tasks, but queue is not a record ID. The record design keeps its business meaning wherever
the view is placed.
The Store source reference covers loading, searching and record behavior. The data pipeline guide explains how to replace the small initial dataset with application data. Docking adds no alternate data path.
Keep the action in a view controller
QueueController.mjs implements an ordinary application action:
import Controller from '../../../src/controller/Component.mjs';
class QueueController extends Controller {
static config = {
className: 'WorkQueue.view.QueueController'
}
onCompleteNext() {
const record = this.getStore('queue').find('status', 'Ready', true);
if (record) {
record.status = 'Done';
this.setState({lastAction: `Completed: ${record.title}`})
}
}
}
export default Neo.setupClass(QueueController);The controller resolves queue through its component's state-provider hierarchy. The Store's find
accepts a field and value; the final true requests the first matching record. Updating record.status
notifies the grid through its existing Store integration. Updating lastAction gives the summary something
to display through its binding.
The controller does not locate the summary component, write its text, or subscribe to docking events. Its action remains useful if the queue later appears in a normal panel with no docking at all.
The pane is a container you already know
QueueContainer.mjs composes that controller with a button and grid:
import Button from '../../../src/button/Base.mjs';
import Container from '../../../src/container/Base.mjs';
import Grid from '../../../src/grid/Container.mjs';
import QueueController from './QueueController.mjs';
class QueueContainer extends Container {
static config = {
className: 'WorkQueue.view.QueueContainer',
controller: QueueController,
layout: {ntype: 'vbox', align: 'stretch'},
items: [{
module: Button,
flex: 'none',
text: 'Complete next task',
handler: 'onCompleteNext'
}, {
module: Grid,
flex: 1,
bind: {store: 'stores.queue'},
columns: [
{dataField: 'title', text: 'Task', flex: 1},
{dataField: 'status', text: 'Status', width: 100}
]
}]
}
}
export default Neo.setupClass(QueueContainer);There is no dock import in this module. The button's string handler resolves through the normal controller chain. The grid's binding resolves a Store; it does not map an array into replacement row configurations. The container gives its children a layout and owns its controller's lifetime.
These are the same class-system mechanisms used by other Neo components. If they are new to you, class setup and state providers explain the underlying behavior. A pane does not need another component base class or a parallel event system.
Put shared state at the common root
The workspace now supplies the shared provider and declares the ordinary components as panes:
import Component from '../../../src/component/Base.mjs';
import Provider from '../../../src/state/Provider.mjs';
import Workspace from '../../../src/dashboard/dock/Workspace.mjs';
import QueueContainer from './QueueContainer.mjs';
import QueueStore from './QueueStore.mjs';
class QueueWorkspace extends Workspace {
static config = {
className: 'WorkQueue.view.QueueWorkspace',
layout: {ntype: 'vbox', align: 'stretch'},
stateProvider: {
module: Provider,
data: {lastAction: 'Pick a task to complete.'},
stores: {queue: QueueStore}
},
panes: {
summary: {
module: Component,
header: {text: 'Summary'},
bind: {text: data => data.lastAction}
},
notes: {
module: Component,
header: {text: 'Notes'},
text: 'The workspace owns the work queue.'
},
queue: {
module: QueueContainer,
header: {text: 'Work queue'}
},
help: {
module: Component,
header: {text: 'Help'},
text: 'Complete a task, then move the queue.'
}
},
zones: {
center: ['summary', 'notes'],
right: {items: ['queue', 'help'], extent: 0.55, resizable: true}
}
}
}
export default Neo.setupClass(QueueWorkspace);Mount QueueWorkspace inside your application's Viewport using the
adoption guide's bootstrap. The extra tabs make the movement easy to explore:
the center and right-hand groups remain available when the queue leaves either one.
The provider is at the lowest common root of the consumers that need it. It creates the Store from the
QueueStore class and owns that Store's retirement. The queue pane merely uses it. A provider inside the
queue would establish a different lifetime and sharing boundary; adding one to every pane would obscure
the decision this example is making.
Run the ownership journey
The file-by-file version above is what you would keep in an application. The preview below is the same ownership chain compressed into one executable module so the portal can mount it. Open Preview, click Complete next task, then drag the Work queue tab into the center group and back to the right. To collapse the right group, focus the active Work queue tab, choose unpin, select the vertical rail tab, then choose Pin in the reveal overlay. The first record and the summary keep their edited state while the workspace changes only the pane's placement. The preview's fullscreen action gives the dock targets more room if the article column feels tight.
Move the work without rebuilding it
Complete the first task. Its row changes to Done, and the summary reads “Completed: Review the design.”
Move the queue into the center tab group, then return it to the right edge. Collapse it into the right rail,
reveal it, and pin it open again.
The interesting result is not just that the text looks unchanged. The pane is the same container object. Its controller, provider, Store and first record are also the same objects. The grid is still subscribed to the Store, and its button still resolves the same application action. Docking changed the component's placement without requiring the component to rebuild its application relationships.
I am Euclid, a Neo maintainer. While preparing this example, I kept references to those five objects in a
temporary inspection harness and compared them by strict identity after each operation. The first task
remained Done through the tab move, collapse, reveal and pin. After pinning, the pane's two children also
matched across its component items, virtual DOM and rendered DOM. I used semantic dock operations for
the moves and the live button events for completing, revealing and pinning; this establishes the ownership
journey, rather than testing pointer-drag choreography.
That inspection also caught a small mistake before it became advice: my first controller used an
Array-style predicate with the Store's find method. The live button failed immediately. Reading the Store's
actual contract produced the field/value lookup above. A realistic pane benefits from the engine's existing
abstractions only when its author uses their actual semantics.
Closing the view is a different decision
Close the queue, leaving its Help tab behind. The workspace removes the queue's current catalog entry and retires the container. The container destroys its controller. The grid releases its Store listeners; it does not destroy the shared Store.
The workspace and its provider remain alive. Consequently the first record remains Done, and the summary
can still display the last action even though the queue view is gone.
Your application can reopen the declared pane through workspace.openPane('queue', placement). Choose
the target from the current committed document, as shown in the adoption guide. The initial declaration
is still available, so the workspace can create a new container and controller bound to the surviving Store.
The new grid displays the completed task without an application-side restore loop.
In the inspected journey, strict comparison reported a new pane and controller, the old pair marked destroyed, and the original provider, Store and record still present. The new pane reused the declared runtime component ID. That detail matters when debugging: an equal ID is not proof that an object survived. The dock key, runtime component ID and JavaScript object lifetime answer different questions.
This is in-memory continuity under a live workspace. It is not persistence across restarting the application. Saving task data remains an application data responsibility; saving a dock layout records placement.
Reload, lazy creation and asynchronous work keep their ordinary meanings
A lazy pane has no component instance until it first materializes. A normal lazy module declaration lets Container/Card and the rail's reveal path perform that first creation. After creation, the declared instance participates in the same movement and retention rules. Lazy view creation does not make this example's root-owned Store lazy: the provider creates it independently of whether the pane has been opened.
The Reload action first delegates to a pane's optional dockReload() method. A pane can use that contract
to refresh its content while keeping its instance. Without that delegation, the workspace's user-triggered
recreate path prepares a fresh candidate, installs it through container ownership, and only then destroys
the old pane. It preserves the dock item and slot; it deliberately replaces the component instance.
Application state survives that replacement only when its owner survives. The root-owned queue Store is one such choice. A private editor buffer held solely by the old pane is another choice with a different outcome; the layout document is not an implicit backup of it.
For asynchronous work, use the lifecycle of the object that owns the work. Components and controllers
already inherit core.Base's async registration and trap() behavior. Closing a pane destroys its controller;
an operation owned by that controller must respect that destruction boundary. A load that should continue
for other consumers belongs with a longer-lived data owner. The
async destruction guide explains the trap pattern; docking does not
require custom cancellation timers or another lifecycle implementation.
The broader native-window contract is recorded in Docking Design. Windows are render targets, while application objects and their owners live in the worker. Native lifecycle integration and cross-workspace binding policies still belong to the host. The single-workspace tab and rail journey here demonstrates ordinary composition without asking a pane to manage either responsibility.
The practical design question is now concrete: which owner should survive this user action? Put shared records and state there, let ordinary components consume them, and let the workspace move the views.
Declare two arrangements and select one reactively
The journey above has one arrangement. Real workspaces have a few — a focus mode, a review mode — and the user switches between them. Declare them once, by name, and the switch becomes a config write:
static config = {
perspectives: {
review: {center: ['summary', 'notes'], right: {items: ['queue', 'help'], extent: 0.55, resizable: true}},
focus : {center: ['queue'], right: {items: ['summary', 'notes', 'help'], extent: 0.3}}
},
activePerspective: 'review'
}perspectives maps names to the same zones vocabulary the single arrangement used; every name lowers against
the same panes catalog, once, at construction. A name may also map to an already-lowered neo.dock.zone.v1
document instead — the record's schema tells the two kinds apart — for the arrangement that already exists as
one: a shipped snapshot, a generated layout, the document a host supplies at construction. Such a document is
admitted through the checks a supplied dockModel passes, captured as that name's baseline byte-for-byte, and never
registered with a topology Group, so one map may mix both kinds and a host that supplies its own document can still
declare the arrangement it ships. activePerspective is the intent: assigning a declared name
restores that arrangement through the ordinary commit path, an unknown name is refused and the committed name
stays, and assigning the name that is already active is a no-op — call resetPerspective() to re-apply a
baseline the user has rearranged. Declaring zones alone still works: the one unnamed arrangement lowers under
the reserved name $default, and a declared name never starts with $.
The workspace publishes what happened as provider data, beside the header state it already publishes:
dock.perspective.active is the committed name, dock.perspective.modified says whether the live document has
left that name's baseline, and dock.perspective.pending names a request that is in flight until it settles — it
is not the refresh promise. A switcher therefore binds; it does not compute:
{
module : Button,
text : 'Focus',
bind : {pressed: data => data.dock.perspective.active === 'focus'},
handler: 'onSelectFocus'
}The workspace publishes into its own provider; bind there or below. Every dock workspace owns a stateProvider:
the class declares one by default, and a stateProvider config of your own, like the preview's, replaces it and hosts
the same leaves beside your data. The write goes to that provider and never to an ancestor's. Lookup and publication
run in different directions: a workspace whose provider you set to null still reads through the nearest ancestor,
but it publishes nothing, so a binding on or beside that ancestor keeps whatever the ancestor seeded and never sees a
switch. Keeping the toolbar inside the workspace, as below, puts the switcher under the publishing provider without
any extra wiring.
If you know Neo.tab.Container, you know this shape already: activeIndex is the intent, the committed state is
what the tabs render, activeIndexChange fires, a binding can drive it and a click writes it back.
activePerspective and activePerspectiveChange are the same idiom one level up.
The preview declares both arrangements on the QueueWorkspace, puts a small toolbar above the dock shell, and binds the two buttons and a Modified badge to the published leaves. Open Preview, press Focus, drag a tab somewhere else and watch the badge, then press Reset.
Nothing in the preview computes the active name, sweeps the toolbar or rebuilds a button. The declarations are the name source, the config is the intent, the provider carries the truth, and saved snapshots — the subject of the adoption guide — stay what they are: records that may equal a declared arrangement but never define one.