diff --git a/src/utilities/data-layer/data-layer-group.js b/src/utilities/data-layer/data-layer-group.js
new file mode 100644
index 0000000..5f06821
--- /dev/null
+++ b/src/utilities/data-layer/data-layer-group.js
@@ -0,0 +1,36 @@
+import { DataLayerItem } from './data-layer-item.js';
+
+export class DataLayerGroup {
+ constructor() {
+ this._items = new Map();
+
+ this._initProperties(Object.getPrototypeOf(this), 'data', (key, value) => {
+ this._items.set(key, new DataLayerItem(value, { callingContext: this }));
+ Object.defineProperty(this, key, {
+ configurable: true,
+ get: () => this._items.get(key).value,
+ set: value => { this._items.get(key).value = value; },
+ });
+ });
+
+ this._initProperties(Object.getPrototypeOf(this), 'actions', (key, value) => {
+ this[key] = value.bind(this);
+ });
+ }
+
+ getItem(key) {
+ return this._items.get(key);
+ }
+
+ _initProperties(base, field, initializer) {
+ const properties = base.constructor[field];
+ if (!properties) return;
+
+ this._initProperties(Object.getPrototypeOf(base));
+
+ Object.keys(properties).forEach(key => {
+ if (key in this) throw new Error(`Cannot define duplicate property ${key} in ${base.constructor.name || 'anonymous class'}`);
+ initializer(key, properties[key]);
+ });
+ }
+}
diff --git a/src/utilities/data-layer/data-layer-item.js b/src/utilities/data-layer/data-layer-item.js
new file mode 100644
index 0000000..a1acb14
--- /dev/null
+++ b/src/utilities/data-layer/data-layer-item.js
@@ -0,0 +1,90 @@
+import { AbortableGroup } from './util/abortable.js';
+
+let __activeComputedValue = null;
+
+export class DataLayerItem {
+ constructor(value, { defaultValue = null, callingContext = this } = {}) {
+ this.evaluating = false;
+
+ this._isComputed = typeof value === 'function';
+ this._subscribers = new Set();
+
+ if (this._isComputed) {
+ this._defaultValue = defaultValue;
+ this._dependenciesEvaluating = new Set();
+ this._getter = value.bind(callingContext);
+ this._inProgressCompute = new AbortableGroup();
+ this._needsFirstCompute = true;
+ this._value = defaultValue;
+ } else {
+ this._value = value;
+ }
+ }
+
+ get value() {
+ __activeComputedValue?.addDependency(this);
+ if (this._needsFirstCompute) this._firstCompute();
+ return this._value;
+ }
+
+ set value(value) {
+ if (this._isComputed) throw new Error('Cannot set value of computed property');
+ this._setValue(value);
+ }
+
+ addDependency(dependency) {
+ if (dependency === this) return;
+
+ dependency.subscribe(this._onDependencyChange.bind(this));
+ if (dependency.evaluating) this._onDependencyChange(dependency);
+ }
+
+ flush() {
+ if (!this._isComputed) return;
+ this._compute();
+ }
+
+ subscribe(callback, immediate = false) {
+ this._subscribers.add(callback);
+ if (immediate) callback(this);
+ }
+
+ _compute() {
+ this._inProgressCompute.abort();
+ this._setValue(this._defaultValue, true);
+ if (this._dependenciesEvaluating.size) return;
+
+ this._inProgressCompute.add().run(this._getter, value => this._setValue(value, false), err => this._onError(err));
+ }
+
+ _firstCompute() {
+ this._needsFirstCompute = false;
+ __activeComputedValue = this;
+ this._compute();
+ __activeComputedValue = null;
+ }
+
+ _notify() {
+ this._subscribers.forEach(callback => callback(this));
+ }
+
+ _onDependencyChange(dependency) {
+ if (dependency.evaluating) this._dependenciesEvaluating.add(dependency);
+ else this._dependenciesEvaluating.delete(dependency);
+ this.flush();
+ }
+
+ _onError(err) {
+ console.error(err);
+ this._setValue(this._defaultValue, false);
+ }
+
+ _setValue(value, evaluating = false) {
+ evaluating = evaluating || this._dependenciesEvaluating?.size > 0;
+ if (this._value === value && this.evaluating === evaluating) return;
+
+ this._value = value;
+ this.evaluating = evaluating;
+ this._notify();
+ }
+}
diff --git a/src/utilities/data-layer/demo-component.js b/src/utilities/data-layer/demo-component.js
new file mode 100644
index 0000000..7f18a92
--- /dev/null
+++ b/src/utilities/data-layer/demo-component.js
@@ -0,0 +1,86 @@
+import '@brightspace-ui/core/components/loading-spinner/loading-spinner.js';
+import { css, html, LitElement } from 'lit';
+import { DataLayerGroup, declareDependencies } from './index.js';
+import { DataLayerController } from './lit/index.js';
+
+class FooData extends DataLayerGroup {
+ static actions = {
+ flushBar() { this.getItem('bar').flush(); },
+ };
+
+ static data = {
+ foo: 'foo',
+ async bar() {
+ declareDependencies(this.foo);
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ return `${this.foo}bar`;
+ },
+ baz() { return `${this.bar}baz`; },
+ async baz2() {
+ declareDependencies(this.bar);
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ return `${this.bar}baz2`;
+ },
+ };
+}
+
+const fooData = new FooData();
+
+class DataLayerDemo extends LitElement {
+ static properties = {
+ linkLoading: { type: Boolean, attribute: 'link-loading' },
+ _data: { state: true },
+ _loading: { state: true },
+ };
+
+ static styles = css`
+ :host {
+ display: block;
+ }
+ `;
+
+ constructor() {
+ super();
+
+ this._data = new DataLayerController(this, fooData, [
+ 'foo',
+ 'bar',
+ 'baz',
+ 'baz2',
+ ]);
+ }
+
+ render() {
+ if (this.linkLoading && this._data.loading) {
+ return html`