diff --git a/package.json b/package.json
index 274075c..fc14154 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"@mantine/core": "^8.2.5",
"@mantine/hooks": "^8.2.5",
"@tanstack/react-form": "^1.19.2",
+ "@tanstack/store": "^0.7.4",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f408c7f..5820d28 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
'@tanstack/react-form':
specifier: ^1.19.2
version: 1.19.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@tanstack/store':
+ specifier: ^0.7.4
+ version: 0.7.4
react:
specifier: ^19.1.1
version: 19.1.1
@@ -553,6 +556,9 @@ packages:
'@tanstack/store@0.7.2':
resolution: {integrity: sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==}
+ '@tanstack/store@0.7.4':
+ resolution: {integrity: sha512-F1XqZQici1Aq6WigEfcxJSml92nW+85Om8ElBMokPNg5glCYVOmPkZGIQeieYFxcPiKTfwo0MTOQpUyJtwncrg==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1702,7 +1708,7 @@ snapshots:
'@tanstack/form-core@1.19.2':
dependencies:
- '@tanstack/store': 0.7.2
+ '@tanstack/store': 0.7.4
'@tanstack/react-form@1.19.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
@@ -1723,6 +1729,8 @@ snapshots:
'@tanstack/store@0.7.2': {}
+ '@tanstack/store@0.7.4': {}
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.3
diff --git a/src/App.tsx b/src/App.tsx
index 8444956..1424002 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,11 +1,29 @@
-import { ProductReviewForm } from "./views/product-details/forms/reviews";
-import { Container, Title } from "@mantine/core";
+import { Button, Container, Text, Title } from "@mantine/core";
+import { ProductDetails } from "./views/product-details";
+import { useForms } from "./contexts/forms";
function App() {
+ const forms = useForms();
+
+ const errors = Object.values(forms.formsState).flatMap((form) => {
+ return form.errors;
+ });
+
return (
- Product Review
-
+ Product Details
+
+ This demonstrates controlling two forms using a single context
+
+
+ {errors.length > 0 && (
+
+ There are {errors.length} errors in the forms
+
+ )}
+
);
}
diff --git a/src/contexts/forms.tsx b/src/contexts/forms.tsx
new file mode 100644
index 0000000..3fb8c19
--- /dev/null
+++ b/src/contexts/forms.tsx
@@ -0,0 +1,82 @@
+import {
+ createContext,
+ type PropsWithChildren,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
+import { type AnyFormApi, useStore } from "@tanstack/react-form";
+import { Derived } from "@tanstack/store";
+
+interface FormsContextType {
+ forms: Record;
+ addForm: (name: string, form: AnyFormApi) => void;
+ removeForm: (name: string) => void;
+}
+
+export const FormsContext = createContext({
+ forms: {},
+} as FormsContextType);
+
+export function FormsProvider({ children }: PropsWithChildren) {
+ const [forms, setForms] = useState>({});
+
+ const addForm = useCallback((name: string, form: AnyFormApi) => {
+ setForms((prev) => ({ ...prev, [name]: form }));
+ }, []);
+
+ const removeForm = useCallback((name: string) => {
+ setForms((prev) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [name]: _, ...rest } = prev;
+ return rest;
+ });
+ }, []);
+
+ const value = useMemo(
+ () => ({ forms, addForm, removeForm }),
+ [forms, addForm, removeForm],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useForms() {
+ const formsContext = useContext(FormsContext);
+
+ const mergedFormStores = useMemo(() => {
+ const stores = Object.values(formsContext.forms).map((form) => form.store);
+
+ return new Derived({
+ deps: stores,
+ fn: () => {
+ return Object.entries(formsContext.forms).reduce(
+ (prev, [name, curr]) => {
+ prev[name as never] = curr.store.state;
+ return prev;
+ },
+ {} as {
+ [K in keyof typeof formsContext.forms]: (typeof formsContext.forms)[K]["store"]["state"];
+ },
+ );
+ },
+ });
+ }, [formsContext]);
+
+ const formsState = useStore(mergedFormStores);
+
+ function submitAll() {
+ Object.values(formsContext.forms).forEach((form) => form.handleSubmit());
+ }
+
+ const forms = formsContext.forms;
+
+ return {
+ formsState,
+ submitAll,
+ forms,
+ };
+}
diff --git a/src/hooks/form.tsx b/src/hooks/form.tsx
index 0de6eec..f39d0aa 100644
--- a/src/hooks/form.tsx
+++ b/src/hooks/form.tsx
@@ -6,6 +6,8 @@ import {
SubmitButton,
FormContainer,
} from "../components/fields";
+import { useContext, useLayoutEffect } from "react";
+import { FormsContext } from "../contexts/forms.tsx";
const {
useAppForm: useInnerAppForm,
@@ -30,6 +32,20 @@ export const useAppForm: typeof useInnerAppForm = (props) => {
...props,
});
+ const context = useContext(FormsContext);
+
+ // These are stable references, but `context` itself is not.
+ const addForm = context.addForm;
+ const removeForm = context.removeForm;
+
+ useLayoutEffect(() => {
+ const id = crypto.randomUUID();
+ addForm(id, form);
+ return () => {
+ removeForm(id);
+ };
+ }, [addForm, removeForm, form]);
+
return form;
};
diff --git a/src/main.tsx b/src/main.tsx
index ea1e385..3ae9559 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
+import { FormsProvider } from "./contexts/forms.tsx";
createRoot(document.getElementById("root")!).render(
-
+
+
+
,
);
diff --git a/src/views/product-details/forms/newsletter/index.tsx b/src/views/product-details/forms/newsletter/index.tsx
new file mode 100644
index 0000000..037684d
--- /dev/null
+++ b/src/views/product-details/forms/newsletter/index.tsx
@@ -0,0 +1,28 @@
+import { useAppForm } from "../../../../hooks/form.tsx";
+import { newsletterSchema, type NewsletterSchema } from "./schema.ts";
+import { SubmitButton } from "../../../../components/fields.tsx";
+
+export function NewsletterForm() {
+ const form = useAppForm({
+ defaultValues: {
+ email: "",
+ } as NewsletterSchema,
+ validators: {
+ onDynamic: newsletterSchema,
+ },
+ onSubmit: (values) => {
+ console.log(values);
+ },
+ });
+
+ return (
+
+
+
+ {(field) => }
+
+ Submit
+
+
+ );
+}
diff --git a/src/views/product-details/forms/newsletter/schema.ts b/src/views/product-details/forms/newsletter/schema.ts
new file mode 100644
index 0000000..d0e1513
--- /dev/null
+++ b/src/views/product-details/forms/newsletter/schema.ts
@@ -0,0 +1,7 @@
+import * as v from "valibot";
+
+export const newsletterSchema = v.object({
+ email: v.pipe(v.string(), v.email("This is not a valid email")),
+});
+
+export type NewsletterSchema = v.InferInput;
diff --git a/src/views/product-details/index.tsx b/src/views/product-details/index.tsx
index 6c5db44..531a3fe 100644
--- a/src/views/product-details/index.tsx
+++ b/src/views/product-details/index.tsx
@@ -1,3 +1,16 @@
-export function ProductDetails() {
+import { NewsletterForm } from "./forms/newsletter";
+import { ProductReviewForm } from "./forms/reviews";
+import { Title } from "@mantine/core";
+export function ProductDetails() {
+ return (
+ <>
+ Product Review
+
+
+ Newsletter Signup
+
+
+ >
+ );
}