diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index caead74b0f1..adde006d626 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -157,10 +157,10 @@ Some application examples for different JavaScript frameworks (such as Next.js,
pnpm dev
```
-3. Run any application in the `playground` folder in development mode, such as `toolpad-core-nextjs`
+3. Run any application in the `playground` folder in development mode, such as `playground-nextjs`
```bash
- cd playground/toolpad-core-nextjs
+ cd playground/playground-nextjs
```
```bash
diff --git a/docs/data/toolpad/core/introduction/Tutorial3.js b/docs/data/toolpad/core/introduction/Tutorial3.js
index b9b89115743..4301cb95b38 100644
--- a/docs/data/toolpad/core/introduction/Tutorial3.js
+++ b/docs/data/toolpad/core/introduction/Tutorial3.js
@@ -1,6 +1,7 @@
import * as React from 'react';
import { createDataProvider, DataContext } from '@toolpad/core/DataProvider';
import { DataGrid } from '@toolpad/core/DataGrid';
+import { useSearchParamState } from '@toolpad/core/useSearchParamState';
import { LineChart } from '@toolpad/core/LineChart';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
@@ -27,7 +28,7 @@ const npmData = createDataProvider({
});
export default function Tutorial3() {
- const [range, setRange] = React.useState('last-month');
+ const [range, setRange] = useSearchParamState('range', 'last-month');
const filter = React.useMemo(() => ({ range: { equals: range } }), [range]);
return (
diff --git a/docs/data/toolpad/core/introduction/Tutorial3.tsx b/docs/data/toolpad/core/introduction/Tutorial3.tsx
index b9b89115743..4301cb95b38 100644
--- a/docs/data/toolpad/core/introduction/Tutorial3.tsx
+++ b/docs/data/toolpad/core/introduction/Tutorial3.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { createDataProvider, DataContext } from '@toolpad/core/DataProvider';
import { DataGrid } from '@toolpad/core/DataGrid';
+import { useSearchParamState } from '@toolpad/core/useSearchParamState';
import { LineChart } from '@toolpad/core/LineChart';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
@@ -27,7 +28,7 @@ const npmData = createDataProvider({
});
export default function Tutorial3() {
- const [range, setRange] = React.useState('last-month');
+ const [range, setRange] = useSearchParamState('range', 'last-month');
const filter = React.useMemo(() => ({ range: { equals: range } }), [range]);
return (
diff --git a/docs/data/toolpad/core/introduction/tutorial.md b/docs/data/toolpad/core/introduction/tutorial.md
index 765a37077e4..68e9b6c6a88 100644
--- a/docs/data/toolpad/core/introduction/tutorial.md
+++ b/docs/data/toolpad/core/introduction/tutorial.md
@@ -200,34 +200,57 @@ The result is the following:
### Global Filtering
-Wrap the dashboard with a `DataContext` to apply global filtering:
+Many dashboards require interactivity. Global filtering that applies to the whole page. Toolpad Core allows creating a data context that centralizes this filtering. Every data provider used under this context has the default filter applied. There is also a `useSearchParamState` hook available that enable you to persist filter values to the url. Just like the `React.useState` hook, it returns a state variable and an set function.
```js
-const [range, setRange] = React.useState('last-month');
+// The range is persisted to the ?range=... url search parameter
+const [range, setRange] = useSearchParamState('range', 'last-month');
const filter = React.useMemo(() => ({ range: { equals: range } }), [range]);
+```
-// ...
+Wrap the dashboard with a `DataContext` to apply global filtering. The filter you pass to this context is applied to any data provider used underneath.
-return (
-
+```js
+export default function App() {
+ const [range, setRange] = useSearchParamState('range', 'last-month');
+ const filter = React.useMemo(() => ({ range: { equals: range } }), [range]);
+ return (
-
- setRange(e.target.value)}
- >
-
-
-
-
- {/* ... */}
+
+
+
+
-
-);
+ );
+}
```
-Any data provider that is used under this context now by default applies this filter.
+The result looks as follows. Try to change the range to see it persisted to the url.
{{"demo": "Tutorial3.js", "hideToolbar": true}}
+
+## Conclusion
+
+This concludes the mini tutorial that brings you from zero to a working dashboard. You can check out the final code of this tutorial with the `create-toolpad-app` CLI:
+
+
+
+
+```bash npm
+npx create-toolpad-app@latest --core --example core-tutorial
+```
+
+```bash pnpm
+pnpm create toolpad-app@latest --core --example core-tutorial
+```
+
+```bash yarn
+yarn create toolpad-app@latest --core --example core-tutorial
+```
+
+
diff --git a/docs/pages/toolpad/core/api/use-search-param-state.json b/docs/pages/toolpad/core/api/use-search-param-state.json
new file mode 100644
index 00000000000..dd2850699c6
--- /dev/null
+++ b/docs/pages/toolpad/core/api/use-search-param-state.json
@@ -0,0 +1,11 @@
+{
+ "parameters": {},
+ "returnValue": {},
+ "name": "useSearchParamState",
+ "filename": "/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx",
+ "imports": [
+ "import useSearchParamState from '/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx';",
+ "import { useSearchParamState } from '/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx';"
+ ],
+ "demos": "
"
+}
diff --git a/docs/translations/api-docs/use-search-param-state/use-search-param-state.json b/docs/translations/api-docs/use-search-param-state/use-search-param-state.json
new file mode 100644
index 00000000000..3a3d4bbce00
--- /dev/null
+++ b/docs/translations/api-docs/use-search-param-state/use-search-param-state.json
@@ -0,0 +1,5 @@
+{
+ "hookDescription": "Works like the React.useState hook, but synchronises the state with a URL query parameter named \"name\".",
+ "parametersDescriptions": {},
+ "returnValueDescriptions": {}
+}
diff --git a/package.json b/package.json
index 36cc9762324..8183616f9ae 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"markdownlint": "markdownlint-cli2 \"**/*.md\"",
"prettier": "pretty-quick --ignore-path .eslintignore",
"prettier:all": "prettier --write . --ignore-path .eslintignore",
- "dev": "dotenv cross-env FORCE_COLOR=1 lerna -- run dev --stream --parallel --ignore docs --ignore toolpad-core-nextjs",
+ "dev": "dotenv cross-env FORCE_COLOR=1 lerna -- run dev --stream --parallel --ignore docs --ignore playground-nextjs",
"docs:dev": "pnpm --filter docs dev",
"docs:build": "pnpm --filter docs build",
"docs:build:api:core": "tsx --tsconfig ./scripts/tsconfig.json ./scripts/docs/buildCoreApiDocs/index.ts",
diff --git a/packages/toolpad-core/src/index.ts b/packages/toolpad-core/src/index.ts
index ac93cc55f24..691c6cb498b 100644
--- a/packages/toolpad-core/src/index.ts
+++ b/packages/toolpad-core/src/index.ts
@@ -7,3 +7,5 @@ export * from './DataProvider';
export * from './DataGrid';
export * from './LineChart';
+
+export * from './useSearchParamState';
diff --git a/packages/toolpad-core/src/useSearchParamState/index.ts b/packages/toolpad-core/src/useSearchParamState/index.ts
new file mode 100644
index 00000000000..5dfc051f74b
--- /dev/null
+++ b/packages/toolpad-core/src/useSearchParamState/index.ts
@@ -0,0 +1 @@
+export * from './useSearchParamState';
diff --git a/packages/toolpad-core/src/useSearchParamState/useSearchParamState.spec.tsx b/packages/toolpad-core/src/useSearchParamState/useSearchParamState.spec.tsx
new file mode 100644
index 00000000000..82227b30567
--- /dev/null
+++ b/packages/toolpad-core/src/useSearchParamState/useSearchParamState.spec.tsx
@@ -0,0 +1,46 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { renderHook } from '@testing-library/react';
+import { describe, expect, test, afterEach, vi } from 'vitest';
+import { useSearchParamState } from './useSearchParamState';
+
+describe('useSearchParamState', () => {
+ afterEach(() => {
+ window.history.pushState({}, '', '');
+ });
+
+ test('should save state to a search parameter', () => {
+ const { result, rerender } = renderHook(() => useSearchParamState('foo', 'bar'));
+
+ expect(result.current[0]).toBe('bar');
+ expect(window.location.search).toBe('');
+ result.current[1]('baz');
+
+ rerender();
+
+ expect(result.current[0]).toBe('baz');
+ expect(window.location.search).toBe('?foo=baz');
+ result.current[1]('bar');
+
+ rerender();
+
+ expect(result.current[0]).toBe('bar');
+ expect(window.location.search).toBe('');
+ });
+
+ test('should read state from a search parameter', () => {
+ window.history.pushState({}, '', '?foo=baz');
+
+ const { result, rerender } = renderHook(() => useSearchParamState('foo', 'bar'));
+
+ expect(result.current[0]).toBe('baz');
+
+ window.history.pushState({}, '', '?foo=bar');
+
+ rerender();
+
+ expect(result.current[0]).toBe('bar');
+ });
+});
diff --git a/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx b/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx
new file mode 100644
index 00000000000..dd3e0a80ce4
--- /dev/null
+++ b/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx
@@ -0,0 +1,132 @@
+import * as React from 'react';
+
+export interface Codec {
+ parse: (value: string) => V;
+ stringify: (value: V) => string;
+}
+
+type UseSearchParamStateOptions = {
+ defaultValue?: V;
+ history?: 'push' | 'replace';
+ codec?: Codec;
+} & (V extends string ? {} : { codec: Codec });
+
+interface NavigationEvent {
+ destination: { url: URL };
+ navigationType: 'push' | 'replace';
+}
+
+const navigateEventHandlers = new Set<(event: NavigationEvent) => void>();
+
+if (typeof window !== 'undefined') {
+ const origHistoryPushState = window.history.pushState;
+ const origHistoryReplaceState = window.history.replaceState;
+ const wrapHistoryMethod = (
+ navigationType: 'push' | 'replace',
+ origMethod: typeof origHistoryPushState,
+ ): typeof origHistoryPushState => {
+ return function historyMethod(this: History, data, title, url?: string | URL | null): void {
+ if (url === null || url === undefined) {
+ return;
+ }
+
+ const event = {
+ destination: { url: new URL(url, window.location.href) },
+ navigationType,
+ };
+
+ Promise.resolve().then(() => {
+ navigateEventHandlers.forEach((handler) => {
+ handler(event);
+ });
+ });
+
+ origMethod.call(this, data, title, url);
+ };
+ };
+ window.history.pushState = wrapHistoryMethod('push', origHistoryPushState);
+ window.history.replaceState = wrapHistoryMethod('replace', origHistoryReplaceState);
+}
+
+function navigate(url: string, options: { history?: 'push' | 'replace' } = {}) {
+ const history = options.history ?? 'push';
+ if (history === 'push') {
+ window.history.pushState(null, '', url);
+ } else {
+ window.history.replaceState(null, '', url);
+ }
+}
+
+function addNavigateEventListener(handler: (event: NavigationEvent) => void) {
+ navigateEventHandlers.add(handler);
+}
+
+function removeNavigateEventListener(handler: (event: NavigationEvent) => void) {
+ navigateEventHandlers.delete(handler);
+}
+
+function encode(codec: Codec, value: V | null): string | null {
+ return value === null ? null : codec.stringify(value);
+}
+
+function decode(codec: Codec, value: string | null): V | null {
+ return value === null ? null : codec.parse(value);
+}
+
+/**
+ * Works like the React.useState hook, but synchronises the state with a URL query parameter named "name".
+ *
+ * API:
+ *
+ * - [useSearchParamState API](https://mui.com/toolpad/core/api/use-search-param-state/)
+ */
+export function useSearchParamState(
+ name: string,
+ initialValue: V,
+ ...args: V extends string ? [UseSearchParamStateOptions?] : [UseSearchParamStateOptions]
+): [V, (newValue: V) => void] {
+ const [options] = args;
+ const { codec } = options ?? {};
+
+ const subscribe = React.useCallback((cb: () => void) => {
+ const handler = () => {
+ cb();
+ };
+ addNavigateEventListener(handler);
+ return () => {
+ removeNavigateEventListener(handler);
+ };
+ }, []);
+ const getSnapshot = React.useCallback(() => {
+ return new URL(window.location.href).searchParams.get(name);
+ }, [name]);
+ const getServerSnapshot = React.useCallback(() => null, []);
+ const rawValue = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+ const setValue = React.useCallback(
+ (value: V | null) => {
+ const url = new URL(window.location.href);
+ const stringValue = codec ? encode(codec, value) : (value as string);
+
+ if (stringValue === null) {
+ url.searchParams.delete(name);
+ } else {
+ const stringDefaultValue = codec ? encode(codec, initialValue) : initialValue;
+
+ if (stringValue === stringDefaultValue) {
+ url.searchParams.delete(name);
+ } else {
+ url.searchParams.set(name, stringValue);
+ }
+ }
+
+ navigate(url.toString(), { history: 'replace' });
+ },
+ [name, codec, initialValue],
+ );
+ const value = React.useMemo(
+ () => (codec && typeof rawValue === 'string' ? decode(codec, rawValue) : (rawValue as V)),
+ [codec, rawValue],
+ );
+
+ return [value ?? initialValue, setValue];
+}
diff --git a/scripts/docs/buildCoreApiDocs/config/getCoreHookInfo.ts b/scripts/docs/buildCoreApiDocs/config/getCoreHookInfo.ts
new file mode 100644
index 00000000000..bcee0fd0693
--- /dev/null
+++ b/scripts/docs/buildCoreApiDocs/config/getCoreHookInfo.ts
@@ -0,0 +1,63 @@
+import fs from 'fs';
+import path from 'path';
+import kebabCase from 'lodash/kebabCase';
+import { getHeaders, getTitle } from '@mui/internal-markdown';
+import {
+ ComponentInfo,
+ HookInfo,
+ extractPackageFile,
+ fixPathname,
+ getApiPath,
+ parseFile,
+} from '@mui-internal/api-docs-builder/buildApiUtils';
+import findPagesMarkdown from '@mui-internal/api-docs-builder/utils/findPagesMarkdown';
+
+export function getCoreHookInfo(filename: string): HookInfo {
+ const { name } = extractPackageFile(filename);
+ let srcInfo: null | ReturnType = null;
+ if (!name) {
+ throw new Error(`Could not find the hook name from: ${filename}`);
+ }
+
+ const allMarkdowns = findPagesMarkdown().map((markdown) => {
+ const markdownContent = fs.readFileSync(markdown.filename, 'utf8');
+ const markdownHeaders = getHeaders(markdownContent) as any;
+
+ return {
+ ...markdown,
+ markdownContent,
+ hooks: markdownHeaders.hooks as string[],
+ };
+ });
+
+ const demos = findCoreHooksDemos(name, allMarkdowns);
+ const apiPath = getApiPath(demos, name);
+
+ return {
+ filename,
+ name,
+ apiPathname: apiPath ?? `/toolpad/core/api/${kebabCase(name)}/`,
+ apiPagesDirectory: path.join(process.cwd(), `docs/pages/toolpad/core/api`),
+ readFile: () => {
+ srcInfo = parseFile(filename);
+ return srcInfo;
+ },
+ getDemos: () => demos,
+ };
+}
+
+function findCoreHooksDemos(
+ hookName: string,
+ pagesMarkdown: ReadonlyArray<{
+ pathname: string;
+ hooks: readonly string[];
+ markdownContent: string;
+ }>,
+) {
+ return pagesMarkdown
+ .filter((page) => page.hooks && page.hooks.includes(hookName))
+ .map((page) => ({
+ demoPageTitle: getTitle(page.markdownContent),
+ demoPathname: `${fixPathname(page.pathname)}#hook${page.hooks?.length > 1 ? 's' : ''}`,
+ }));
+}
diff --git a/scripts/docs/buildCoreApiDocs/config/projectSettings.ts b/scripts/docs/buildCoreApiDocs/config/projectSettings.ts
index 2d2bc4816f3..cfdad1cdd18 100644
--- a/scripts/docs/buildCoreApiDocs/config/projectSettings.ts
+++ b/scripts/docs/buildCoreApiDocs/config/projectSettings.ts
@@ -3,6 +3,7 @@ import { ProjectSettings } from '@mui-internal/api-docs-builder';
import findApiPages from '@mui-internal/api-docs-builder/utils/findApiPages';
import { LANGUAGES } from '../../../../docs/config';
import { getCoreComponentInfo } from './getCoreComponentInfo';
+import { getCoreHookInfo } from './getCoreHookInfo';
import { getComponentImports } from './getComponentImports';
export const projectSettings: ProjectSettings = {
@@ -18,6 +19,7 @@ export const projectSettings: ProjectSettings = {
],
getApiPages: () => findApiPages('docs/pages/toolpad/core/api'),
getComponentInfo: getCoreComponentInfo,
+ getHookInfo: getCoreHookInfo,
getComponentImports,
translationLanguages: LANGUAGES,
skipComponent: () => false,