diff --git a/package.json b/package.json
index 3a79084e..fd9dd7b6 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,9 @@
"test": "jest"
},
"license": "APACHE 2.0",
+ "dependencies": {
+ "use-sync-external-store": "^1.6.0"
+ },
"devDependencies": {
"@babel/core": "^7.17.8",
"@babel/plugin-proposal-class-properties": "^7.16.7",
diff --git a/readme.md b/readme.md
index e03182e0..2c50a158 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/__tests__/clock-context.test.js b/src/components/__tests__/clock-context.test.js
new file mode 100644
index 00000000..764a5997
--- /dev/null
+++ b/src/components/__tests__/clock-context.test.js
@@ -0,0 +1,126 @@
+/**
+ * @jest-environment jsdom
+ *
+ * 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.
+ **/
+
+import React from 'react';
+import { render, screen, act } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { ClockProvider, useClock, useClockSelector } from '../clock-context';
+
+// Capture the onTick prop the Clock component receives so tests can drive ticks
+// without depending on Clock's real timer / server-sync behavior.
+let lastOnTick = null;
+jest.mock('../clock', () => ({
+ __esModule: true,
+ default: ({ onTick }) => {
+ lastOnTick = onTick;
+ return null;
+ }
+}));
+
+beforeEach(() => { lastOnTick = null; });
+
+const ClockReader = () => {
+ const now = useClock();
+ return {now === null ? 'null' : String(now)};
+};
+
+const SelectorReader = ({ select, label = 'sel' }) => {
+ const value = useClockSelector(select);
+ return {value === null || value === undefined ? 'null' : String(value)};
+};
+
+describe('ClockProvider', () => {
+ it('renders children', () => {
+ render(
child
);
+ expect(screen.getByText('child')).toBeInTheDocument();
+ });
+
+ it('mounts the Clock component and exposes its emit hook', () => {
+ render();
+ expect(typeof lastOnTick).toBe('function');
+ });
+});
+
+describe('useClock', () => {
+ it('throws when used outside ClockProvider', () => {
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ expect(() => render()).toThrow();
+ } finally {
+ errorSpy.mockRestore();
+ }
+ });
+
+ it('returns null before the first tick', () => {
+ render();
+ expect(screen.getByTestId('now')).toHaveTextContent('null');
+ });
+
+ it('returns the latest emitted timestamp', () => {
+ render();
+ act(() => lastOnTick(1700000000));
+ expect(screen.getByTestId('now')).toHaveTextContent('1700000000');
+ act(() => lastOnTick(1700000001));
+ expect(screen.getByTestId('now')).toHaveTextContent('1700000001');
+ });
+});
+
+describe('useClockSelector', () => {
+ it('throws when used outside ClockProvider', () => {
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ expect(() => render( t} />)).toThrow();
+ } finally {
+ errorSpy.mockRestore();
+ }
+ });
+
+ it('computes derived value from the latest tick', () => {
+ render(
+
+ (t ? `tick:${t}` : null)} />
+
+ );
+ act(() => lastOnTick(1700000000));
+ expect(screen.getByTestId('sel')).toHaveTextContent('tick:1700000000');
+ act(() => lastOnTick(1700000001));
+ expect(screen.getByTestId('sel')).toHaveTextContent('tick:1700000001');
+ });
+
+ it('only re-renders when the selected value changes across ticks', () => {
+ let renders = 0;
+ const Counted = () => {
+ renders++;
+ const phase = useClockSelector((t) => (t > 1700000000 ? 'after' : 'before'));
+ return {phase ?? 'null'};
+ };
+ render();
+
+ // First tick: selector goes from null (initial cache) to 'before' — one re-render.
+ act(() => lastOnTick(1699999999));
+ const afterFirst = renders;
+ expect(screen.getByTestId('phase')).toHaveTextContent('before');
+
+ // Second tick: selected value is still 'before' — must NOT re-render.
+ act(() => lastOnTick(1700000000));
+ expect(screen.getByTestId('phase')).toHaveTextContent('before');
+ expect(renders).toBe(afterFirst);
+
+ // Third tick: selected value flips to 'after' — one re-render.
+ act(() => lastOnTick(1700000001));
+ expect(screen.getByTestId('phase')).toHaveTextContent('after');
+ expect(renders).toBe(afterFirst + 1);
+ });
+});
diff --git a/src/components/clock-context.js b/src/components/clock-context.js
new file mode 100644
index 00000000..0ef5ec2f
--- /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 cc3dff08..9448ed35 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -40,6 +40,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 00000000..4644113a
--- /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