diff --git a/package.json b/package.json
index b29aa43..ab6f19c 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,9 @@
"test": "jest"
},
"license": "APACHE 2.0",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.0"
+ },
"devDependencies": {
"@babel/core": "^7.17.8",
"@babel/plugin-proposal-class-properties": "^7.16.7",
@@ -37,6 +40,8 @@
"dropzone": "5.7.2",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.6",
+ "@testing-library/react": "^12.1.5",
+ "@testing-library/jest-dom": "^5.16.5",
"extend": "^3.0.1",
"file-loader": "^6.2.0",
"final-form": "^4.20.7",
diff --git a/readme.md b/readme.md
index e03182e..2c50a15 100644
--- a/readme.md
+++ b/readme.md
@@ -18,5 +18,9 @@ import 'sweetalert2/dist/sweetalert2.css';
1 - yarn build && yarn publish
+## React compatibility
+
+`createExternalStore` (and the clock context built on it) uses the `use-sync-external-store` shim for React 16/17 compatibility. When React is upgraded to 18+, replace the shim with the native import from `react` and remove the `use-sync-external-store` dependency from package.json.
+
## Troubleshoot
For Python 3.13 and above, yarn install will not work until you install this lib: sudo apt install python3-setuptools
diff --git a/src/components/clock-context.js b/src/components/clock-context.js
new file mode 100644
index 0000000..0ef5ec2
--- /dev/null
+++ b/src/components/clock-context.js
@@ -0,0 +1,64 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Pre-built clock store using createExternalStore.
+ *
+ * Wires the Clock component (server-synced, ticks every second) into a
+ * createExternalStore instance. Components choose their update strategy:
+ *
+ * - useClock() re-renders every second (for countdowns, live displays)
+ * - useClockSelector(compute) only re-renders when the computed result changes
+ * - Components that use neither are never affected by clock ticks
+ *
+ * Usage:
+ * import { ClockProvider, useClock, useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context';
+ *
+ *
+ *
+ *
+ *
+ * const nowUtc = useClock();
+ *
+ * const phase = useClockSelector((nowUtc) => {
+ * if (nowUtc < event.start) return 'before';
+ * if (nowUtc <= event.end) return 'during';
+ * return 'after';
+ * });
+ *
+ * For custom (non-clock) stores, see createExternalStore in utils/external-store.js.
+ **/
+
+import React from 'react';
+import { createExternalStore } from '../utils/external-store';
+import Clock from './clock';
+
+const { Provider, useValue: useClock, useSelector: useClockSelector } = createExternalStore('Clock');
+
+/**
+ * ClockProvider - Wraps your app with server-synced clock context.
+ *
+ * @param {string} timezone - Timezone for the clock (e.g., "America/New_York")
+ * @param {number} now - Optional initial timestamp (for testing or manual override)
+ * @param {React.ReactNode} children - Child components
+ */
+export const ClockProvider = ({ timezone, now, children }) => (
+
+ {(emit) => (
+ <>
+
+ {children}
+ >
+ )}
+
+);
+
+export { useClock, useClockSelector };
diff --git a/src/components/index.js b/src/components/index.js
index c610b6a..5d7fb5f 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -43,6 +43,8 @@ export {default as LanguageInput} from './inputs/language-input'
export {default as FreeMultiTextInput} from "./inputs/free-multi-text-input";
export {default as Exclusive} from "./exclusive-wrapper";
export {default as Clock} from "./clock";
+export {ClockProvider, useClock, useClockSelector} from "./clock-context";
+export {createExternalStore} from "../utils/external-store";
export {default as CircleButton} from "./circle-button";
export {default as VideoStream} from "./video-stream";
export {default as AttendanceTracker} from "./attendance-tracker";
diff --git a/src/utils/__tests__/external-store.test.js b/src/utils/__tests__/external-store.test.js
new file mode 100644
index 0000000..4644113
--- /dev/null
+++ b/src/utils/__tests__/external-store.test.js
@@ -0,0 +1,346 @@
+/**
+ * @jest-environment jsdom
+ */
+import React from 'react';
+import { render, screen, act } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { createExternalStore } from '../external-store';
+
+describe('createExternalStore', () => {
+ let store;
+ let emit;
+
+ // Test component that captures emit from the render function
+ const TestProvider = ({ children }) => (
+
+ {(emitFn) => {
+ emit = emitFn;
+ return children;
+ }}
+
+ );
+
+ // Test component that uses useValue
+ const ValueDisplay = ({ onRender }) => {
+ const value = store.useValue();
+ onRender?.();
+ return
{value}
;
+ };
+
+ // Test component that uses useSelector
+ const SelectorDisplay = ({ compute, isEqual, onRender }) => {
+ const value = store.useSelector(compute, isEqual);
+ onRender?.();
+ return {JSON.stringify(value)}
;
+ };
+
+ beforeEach(() => {
+ store = createExternalStore('Test');
+ emit = null;
+ });
+
+ describe('Provider', () => {
+ it('renders children', () => {
+ render(
+
+ hello
+
+ );
+ expect(screen.getByTestId('child')).toHaveTextContent('hello');
+ });
+
+ it('passes emit function to render prop', () => {
+ render();
+ expect(typeof emit).toBe('function');
+ });
+ });
+
+ describe('useValue', () => {
+ it('throws when used outside Provider', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ render();
+ }).toThrow('Test hooks must be used within their Provider');
+
+ consoleError.mockRestore();
+ });
+
+ it('returns null before first emit', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('value')).toHaveTextContent('');
+ });
+
+ it('returns current value after emit', () => {
+ render(
+
+
+
+ );
+
+ act(() => emit(42));
+ expect(screen.getByTestId('value')).toHaveTextContent('42');
+ });
+
+ it('re-renders on every emit', () => {
+ const renderCount = jest.fn();
+
+ render(
+
+
+
+ );
+
+ const initial = renderCount.mock.calls.length;
+
+ act(() => emit(1));
+ act(() => emit(2));
+ act(() => emit(3));
+
+ expect(renderCount.mock.calls.length).toBe(initial + 3);
+ });
+
+ it('updates displayed value on each emit', () => {
+ render(
+
+
+
+ );
+
+ act(() => emit('first'));
+ expect(screen.getByTestId('value')).toHaveTextContent('first');
+
+ act(() => emit('second'));
+ expect(screen.getByTestId('value')).toHaveTextContent('second');
+ });
+ });
+
+ describe('useSelector', () => {
+ it('throws when used outside Provider', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ render( v} />);
+ }).toThrow('Test hooks must be used within their Provider');
+
+ consoleError.mockRestore();
+ });
+
+ it('computes derived value', () => {
+ const compute = (v) => v ? v * 2 : null;
+
+ render(
+
+
+
+ );
+
+ act(() => emit(50));
+ expect(screen.getByTestId('selector')).toHaveTextContent('100');
+ });
+
+ it('only re-renders when computed value changes', () => {
+ const renderCount = jest.fn();
+
+ // Returns "low" or "high" based on threshold
+ const compute = (v) => {
+ if (!v) return null;
+ return v < 100 ? 'low' : 'high';
+ };
+
+ render(
+
+
+
+ );
+
+ const initial = renderCount.mock.calls.length;
+
+ // All "low" - only first causes re-render
+ act(() => emit(10));
+ act(() => emit(20));
+ act(() => emit(30));
+ expect(renderCount.mock.calls.length).toBe(initial + 1);
+ expect(screen.getByTestId('selector')).toHaveTextContent('low');
+
+ // Crosses to "high" - re-render
+ act(() => emit(150));
+ expect(renderCount.mock.calls.length).toBe(initial + 2);
+ expect(screen.getByTestId('selector')).toHaveTextContent('high');
+
+ // Still "high" - no re-render
+ act(() => emit(200));
+ expect(renderCount.mock.calls.length).toBe(initial + 2);
+ });
+
+ it('uses custom equality function', () => {
+ const renderCount = jest.fn();
+
+ const compute = (v) => {
+ if (!v) return [];
+ if (v < 100) return [{ id: 1 }, { id: 2 }];
+ return [{ id: 1 }];
+ };
+
+ const isEqual = (a, b) => {
+ if (a.length !== b.length) return false;
+ return a.every((item, i) => item.id === b[i]?.id);
+ };
+
+ render(
+
+
+
+ );
+
+ const initial = renderCount.mock.calls.length;
+
+ // Same result [1,2] - no re-render after first
+ act(() => emit(10));
+ act(() => emit(20));
+ act(() => emit(30));
+ expect(renderCount.mock.calls.length).toBe(initial + 1);
+
+ // Result changes to [1] - re-render
+ act(() => emit(150));
+ expect(renderCount.mock.calls.length).toBe(initial + 2);
+ });
+
+ it('recomputes when compute function changes', () => {
+ const { rerender } = render(
+
+ v ? 'A' : null} />
+
+ );
+
+ act(() => emit(1));
+ expect(screen.getByTestId('selector')).toHaveTextContent('A');
+
+ rerender(
+
+ v ? 'B' : null} />
+
+ );
+
+ expect(screen.getByTestId('selector')).toHaveTextContent('B');
+ });
+ });
+
+ describe('multiple subscribers', () => {
+ it('supports multiple useValue subscribers', () => {
+ const render1 = jest.fn();
+ const render2 = jest.fn();
+
+ render(
+
+
+
+
+ );
+
+ act(() => emit(1));
+
+ expect(render1).toHaveBeenCalled();
+ expect(render2).toHaveBeenCalled();
+ });
+
+ it('mixed useValue and useSelector subscribers', () => {
+ const valueRenders = jest.fn();
+ const selectorRenders = jest.fn();
+
+ // Selector only changes every 100
+ const compute = (v) => v ? Math.floor(v / 100) : null;
+
+ render(
+
+
+
+
+ );
+
+ const initialValue = valueRenders.mock.calls.length;
+ const initialSelector = selectorRenders.mock.calls.length;
+
+ // 3 emits within same "bucket"
+ act(() => emit(10));
+ act(() => emit(20));
+ act(() => emit(30));
+
+ // useValue re-renders every time
+ expect(valueRenders.mock.calls.length).toBe(initialValue + 3);
+
+ // useSelector only re-renders once (value stays 0)
+ expect(selectorRenders.mock.calls.length).toBe(initialSelector + 1);
+ });
+ });
+
+ describe('multiple independent stores', () => {
+ it('stores are isolated from each other', () => {
+ const store1 = createExternalStore('Store1');
+ const store2 = createExternalStore('Store2');
+
+ let emit1, emit2;
+
+ const Display1 = () => {
+ const v = store1.useValue();
+ return {v}
;
+ };
+
+ const Display2 = () => {
+ const v = store2.useValue();
+ return {v}
;
+ };
+
+ render(
+
+ {(e) => {
+ emit1 = e;
+ return (
+
+ {(e2) => {
+ emit2 = e2;
+ return (
+ <>
+
+
+ >
+ );
+ }}
+
+ );
+ }}
+
+ );
+
+ act(() => emit1('hello'));
+ expect(screen.getByTestId('s1')).toHaveTextContent('hello');
+ expect(screen.getByTestId('s2')).toHaveTextContent('');
+
+ act(() => emit2('world'));
+ expect(screen.getByTestId('s1')).toHaveTextContent('hello');
+ expect(screen.getByTestId('s2')).toHaveTextContent('world');
+ });
+ });
+
+ describe('error messages', () => {
+ it('includes store name in error message', () => {
+ const namedStore = createExternalStore('MyCustomStore');
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ const Component = () => {
+ namedStore.useValue();
+ return null;
+ };
+
+ expect(() => {
+ render();
+ }).toThrow('MyCustomStore hooks must be used within their Provider');
+
+ consoleError.mockRestore();
+ });
+ });
+});
diff --git a/src/utils/external-store.js b/src/utils/external-store.js
new file mode 100644
index 0000000..60abe02
--- /dev/null
+++ b/src/utils/external-store.js
@@ -0,0 +1,201 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * createExternalStore - Factory for creating React-optimized external stores.
+ *
+ * Problem:
+ * When a frequently-updating data source (clock, WebSocket, polling, etc.)
+ * pushes its value into shared state (global store, lifted useState, or a
+ * context value), every consuming component re-renders on every update,
+ * even when they only care about a derived condition that rarely changes.
+ *
+ * Solution:
+ * createExternalStore() returns a Provider and hooks that store the value
+ * in a ref (no re-renders) and use useSyncExternalStore so components
+ * can opt in to updates selectively:
+ *
+ * - useValue() → re-renders on every update
+ * - useSelector(compute, isEqual) → re-renders only when the computed result changes
+ *
+ * Components that don't call either hook are never affected by updates.
+ *
+ * How it works:
+ * 1. The Provider stores the value in a ref (writing to a ref never triggers
+ * a React re-render) and keeps a Set of listener callbacks.
+ * 2. When emit(value) is called, the ref is updated and all listeners are
+ * notified. These listeners come from useSyncExternalStore.
+ * 3. useSyncExternalStore (React 18, shimmed for 16/17) calls getSnapshot()
+ * to read the ref, compares with the previous value, and only re-renders
+ * the component if the value changed.
+ * 4. useMemo adds a layer on top: it runs a compute function on the raw value
+ * and only re-renders if the computed result changed (checked via isEqual).
+ *
+ * API:
+ * createExternalStore(name) returns:
+ *
+ * - Provider Wraps your component tree. Pass children as a render function
+ * to receive the emit callback: (emit) => JSX. Call emit(value)
+ * each time your data source has a new value.
+ *
+ * - useValue() Returns the latest emitted value. The component re-renders
+ * on every emit.
+ *
+ * - useSelector(compute, isEqual?)
+ * Returns a derived value. compute(rawValue) runs on every emit,
+ * but the component only re-renders when isEqual returns false
+ * (default: ===). Useful when you need to derive something that
+ * changes less frequently than the raw value.
+ *
+ * The name parameter is used in error messages. For example,
+ * createExternalStore('Clock') throws "Clock hooks must be used within
+ * their Provider" when a hook is called outside the Provider.
+ *
+ * For clock-specific usage:
+ * A pre-built clock store is available at:
+ * import { ClockProvider, useClock, useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context';
+ * This wires createExternalStore to the Clock component so projects don't
+ * have to repeat that boilerplate.
+ *
+ * Custom store example:
+ * import { createExternalStore } from 'openstack-uicore-foundation/lib/utils/external-store';
+ *
+ * const { Provider, useValue, useSelector } = createExternalStore('WebSocket');
+ *
+ * const WebSocketProvider = ({ url, children }) => (
+ *
+ * {(emit) => (
+ * <>
+ *
+ * {children}
+ * >
+ * )}
+ *
+ * );
+ *
+ * // Re-renders on every message:
+ * const message = useValue();
+ *
+ * // Re-renders only when the derived value changes:
+ * const isActive = useSelector((msg) => msg?.status === 'active');
+ **/
+
+import React, { createContext, useContext, useRef, useCallback, useMemo as reactUseMemo } from 'react';
+// Shim for React 16/17 compatibility, falls back to native in React 18+
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+
+const strictEqual = (a, b) => a === b;
+
+/**
+ * Creates an external store with a Provider and subscription hooks.
+ *
+ * @param {string} name - Store name, used in error messages (e.g., "Clock", "WebSocket")
+ * @returns {{ Provider, useValue, useSelector }}
+ */
+export function createExternalStore(name = 'ExternalStore') {
+ const Context = createContext(null);
+
+ const useStoreContext = () => {
+ const context = useContext(Context);
+ if (context === null) {
+ throw new Error(`${name} hooks must be used within their Provider`);
+ }
+ return context;
+ };
+
+ /**
+ * Provider - Wraps your component tree and provides the store.
+ *
+ * Pass children as a render function to receive the `emit` callback:
+ * {(emit) => }
+ *
+ * Or pass children normally if you wire emit externally.
+ */
+ const Provider = ({ children }) => {
+ const valueRef = useRef(null);
+ const listenersRef = useRef(new Set());
+
+ const subscribe = useCallback((callback) => {
+ listenersRef.current.add(callback);
+ return () => listenersRef.current.delete(callback);
+ }, []);
+
+ const getSnapshot = useCallback(() => valueRef.current, []);
+
+ const emit = useCallback((value) => {
+ valueRef.current = value;
+ listenersRef.current.forEach(listener => listener());
+ }, []);
+
+ const contextValue = reactUseMemo(() => ({ subscribe, getSnapshot }), [subscribe, getSnapshot]);
+
+ return (
+
+ {typeof children === 'function' ? children(emit) : children}
+
+ );
+ };
+
+ /**
+ * useValue - Subscribe to every update.
+ * Component re-renders each time emit() is called.
+ *
+ * @returns {*} The current value, or null before first emit
+ */
+ const useValue = () => {
+ const { subscribe, getSnapshot } = useStoreContext();
+ return useSyncExternalStore(subscribe, getSnapshot);
+ };
+
+ /**
+ * useSelector - Subscribe with a selector function.
+ * Only re-renders when the selected/derived value changes.
+ *
+ * @param {Function} compute - (value) => derivedValue
+ * @param {Function} isEqual - Optional equality function (default: ===)
+ * @returns {*} The computed value
+ */
+ const useSelector = (compute, isEqual = strictEqual) => {
+ const { subscribe, getSnapshot } = useStoreContext();
+
+ const lastResultRef = useRef(null);
+ const lastValueRef = useRef(null);
+ const lastComputeRef = useRef(compute);
+
+ // Invalidate cache when compute function changes
+ if (lastComputeRef.current !== compute) {
+ lastComputeRef.current = compute;
+ lastValueRef.current = null;
+ }
+
+ const getComputedValue = useCallback(() => {
+ const value = getSnapshot();
+
+ if (value === lastValueRef.current) {
+ return lastResultRef.current;
+ }
+
+ const newResult = compute(value);
+ lastValueRef.current = value;
+
+ if (lastResultRef.current !== null && isEqual(lastResultRef.current, newResult)) {
+ return lastResultRef.current;
+ }
+
+ lastResultRef.current = newResult;
+ return newResult;
+ }, [getSnapshot, compute, isEqual]);
+
+ return useSyncExternalStore(subscribe, getComputedValue);
+ };
+
+ return { Provider, useValue, useSelector };
+}
diff --git a/webpack.common.js b/webpack.common.js
index 49847dd..42b24a8 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -31,6 +31,7 @@ module.exports = {
'components/table-sortable': './src/components/table-sortable/SortableTable.js',
'components/attendance-tracker': './src/components/attendance-tracker.js',
'components/clock': './src/components/clock.js',
+ 'components/clock-context': './src/components/clock-context.js',
'components/exclusive-wrapper': './src/components/exclusive-wrapper.js',
'components/video-stream': './src/components/video-stream.js',
'components/inputs/action-dropdown': './src/components/inputs/action-dropdown/index.js',
@@ -90,6 +91,7 @@ module.exports = {
'i18n': './src/i18n/i18n.js',
'utils/questions-set': './src/utils/questions-set.js',
'utils/money': './src/utils/money.js',
+ 'utils/external-store': './src/utils/external-store.js',
},
output: {
path: path.resolve(__dirname, 'lib'),