From 8576fea8fbd3b698fd1b783c226077b0602990c2 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 22 Aug 2025 03:25:01 -0700 Subject: [PATCH] feat: add initial form manager logic --- package.json | 1 + pnpm-lock.yaml | 10 ++- src/App.tsx | 26 +++++- src/contexts/forms.tsx | 82 +++++++++++++++++++ src/hooks/form.tsx | 16 ++++ src/main.tsx | 5 +- .../forms/newsletter/index.tsx | 28 +++++++ .../forms/newsletter/schema.ts | 7 ++ src/views/product-details/index.tsx | 15 +++- 9 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 src/contexts/forms.tsx create mode 100644 src/views/product-details/forms/newsletter/index.tsx create mode 100644 src/views/product-details/forms/newsletter/schema.ts 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 + + + + ); } -- 2.51.2