Skip to main content

SharedTree Quick Start

SharedTree is a distributed data structure that looks and feels like simple JavaScript objects with a type safe wrapper. This guide will walk you through the basics of creating, configuring, and interacting with a SharedTree in your application.

Installing SharedTree

The SharedTree library can be found in the fluid-framework package (version 2.x).

To get started, run the following from a terminal in your project folder:

npm install fluid-framework@latest

Creating a Tree

A SharedTree can be created by defining a ContainerSchema with an initial object of type SharedTree and using this schema to create and load your container. This example creates a container using an Azure specific client.

note: enableRuntimeIdCompressor must be enabled in the container runtime options in order to use SharedTree

See more info on creating and loading containers here.

import { AzureClient } from "@fluidframework/azure-client";
import { ContainerSchema, SharedTree } from "fluid-framework";

// clientProps specifies connection details and is defined by the app author
const client = new AzureClient(clientProps);

const containerSchema: ContainerSchema = {
initialObjects: {
appData: SharedTree,
},
};

const { container } = await client.createContainer(containerSchema, "2");

After creating a SharedTree, you need to create a TreeView. A TreeView provides the interface for reading and editing data on the SharedTree using a particular schema. This is done by calling viewWith on the SharedTree with your tree configuration.

const treeConfiguration = new TreeViewConfiguration({ schema: TodoList });

const appData = container.initialObjects.appData.viewWith(treeConfiguration);

The tree configuration takes in a schema for the root of the tree which will need to be defined by your application. This example uses the TodoList schema, which we define in the next section, as the root schema.

Defining a Schema

A schema outlines the structure and types of data that your tree will manage, providing useful guarantees about the shape your data will take. It is used to generate TypeScript types to make working with your data more ergonomic. The data also behaves like JS objects which makes it easy to use in external libraries or systems.

To define a schema, first create a SchemaFactory with a unique string to use as the namespace.

const schemaFactory = new SchemaFactory("some-schema-id-prob-a-uuid");

SchemaFactory provides some methods for specifying collection types including object(), array(), and map(); and five primitive data types for specifying leaf nodes: boolean, string, number, null, and handle. See schema definition for more info on the provided types.

You can define a schema by extending one of the built-in object types. As an example, let's write a schema for a todo list:

class TodoList extends schemaFactory.object("TodoList", {
title: schemaFactory.string,
items: todoItems,
});

class TodoItems extends schemaFactory.array("TodoItems", TodoItem);

class TodoItem extends schemaFactory.object("TodoItem", {
description: schemaFactory.string,
isCompleted: schemaFactory.boolean,
});

This creates a TodoList class that is an object schema with two fields, title and items. title is a leaf node of type string while items stores TodoItems which is an array of TodoItem. TodoItem is an object with two primitive values, description and isCompleted.

Schemas can also be defined using plain old JavaScript object (POJO) mode. Generally, you should prefer customizable mode. See the API docs for more info on the differences between the two modes.

Initializing the Tree

Once your view is created, you need to initialize it with some data. This can only be done once after the tree is created and the data you initialize your tree with must conform to the schema that you provided.

This is how we would initialize our todo list:

appData.initialize(
new TodoList({
title: "todo list",
items: [
new TodoItem({
description: "first item",
isComplete: true,
}),
],
}),
);

Reading Data From Your Tree

Data can be read from the tree using the root property of the TreeView that you obtained through the viewWith method. Since the root of our tree is an object schema, its data can be accessed like a regular JavaScript object.

If TodoList is the root schema of your tree, you can access the todo list data like this:

const todoListTitle = appData.root.title;
const firstItem = appData.root.items[0];

You can also iterate through the actual items, here's an example of how to use the data you're reading in a JSX component:

<li>
{appData.root.items.map((item) =>
<ul>{item.description}</ul>
)}
</li>

When using the data in your tree, it's important to listen to the change events provided by the tree so that you can make any necessary updates to your application. This is how you can use React hooks to listen to the nodeChanged event:

const [itemCount, setCount] = useState(appData.root.items.length);

useEffect(() => {
const unsubscribe = Tree.on(appData.root.items, "nodeChanged", () => {
setCount(appData.root.items.length);
});
return unsubscribe;
}, []);

nodeChanged fires whenever one or more properties of the specified node change while treeChanged also fires whenever any node in its subtree changes.

See the API docs for more details.

Editing Tree Data

There are built-in editing methods for each of the provided schema types. For example, if your data is in an array, you can add a new todo item at index 3 like this:

appData.root.items.insertAt(
3,
new TodoItem({
description: "new item",
isComplete: false,
}),
);

The schema types can also be edited using the assignment operator like this:

appData.root.title = "chores";

Editing methods can also be defined on the schema classes defined. This allows you to write methods that are more suited to your app's specific needs.

class TodoList extends schemaFactory.object("TodoList", {
title: schemaFactory.string,
items: TodoItems,
}) {
public removeFirst = () => {
if (this.length > 0) this.removeAt(0);
};
}

These methods are designed to merge well in collaborative settings without you having to think much about it. See schema definition for more details on the built-in editing methods. You can also read more about how these editing operations work in collaborative settings here.

Grouping Edits into Transactions

Edits can be grouped into transaction using the Tree.runTransaction() API. It can be useful to group edits because a transaction is equivalent to a single unit when undoing and redoing.

Tree.runTransaction(myNode, (node) => {
// Make multiple changes to the tree.
// These can be changes to the referenced node but are not limited to that scope.

if ( /* Something went wrong, abort! */ ) {
return Tree.runTransaction.rollback;
}
})

See more information on transactions here.

Undoing and Redoing Edits

Calling revert on a Revertible will revert the associated edit on the associated view.

A revertible can be obtained by listening to the commitApplied event and calling the getRevertible callback provided. The kind property on CommitMetadata tells you if a commit is the result of a normal edit, an undo, or a redo. This aids in managing separate undo and redo stacks. Here is a simple example of how to do so using the provided APIs:

const undoStack = [];
const redoStack = [];

useEffect(() => {
const unsubscribe = appData.events.on(
"commitApplied",
(commit: CommitMetadata, getRevertible?: RevertibleFactory) => {
if (getRevertible === undefined) {
return;
}
const revertible = getRevertible();
if (commit.kind === CommitKind.Undo) {
redoStack.push(revertible);
} else {
if (commit.kind === CommitKind.Default) {
// clear redo stack
for (const redo of redoStack) {
redo.dispose();
}
redoStack.length = 0;
}
undoStack.push(revertible);
}
},
);
return unsubscribe;
}, []);

Note that any Revertibles obtained should be disposed of by the app author in order to free up the resources that are required to revert an edit.

See this blog post or undo redo support for more information.