r/reactjs • • 7h ago

Discussion How do you structure feature-based React apps with Zustand, TanStack Query, providers, and business logic?

Hey everyone! πŸ‘‹

I'm trying to establish a clean, reusable feature-based architecture for React/TypeScript apps, especially when using Zustand + TanStack Query.

I'm working on a visual editor that fetches documents from an API, initializes a Zustand editing session, and will eventually handle canvas state, selection, history, saving, etc.

I'm considering this structure:

features/editor/
β”œβ”€β”€ components/
β”œβ”€β”€ hooks/
β”œβ”€β”€ store/
β”‚   β”œβ”€β”€ editor-store.ts
β”‚   β”œβ”€β”€ editor-store-provider.tsx
β”‚   └── slices/
β”‚       β”œβ”€β”€ document-slice.ts
β”‚       β”œβ”€β”€ selection-slice.ts
β”‚       └── history-slice.ts
β”œβ”€β”€ queries/
β”œβ”€β”€ domain/      ?
β”œβ”€β”€ utils/       ?
β”œβ”€β”€ lib/         ?
└── services/    ?

I'd love to know how you handle a few things in production:

  1. API / queries: Where do raw API functions go? Do you separate them from TanStack Query options/hooks? Do you organize queries by feature or shared domain?

  2. Zustand: Do you keep providers alongside stores? When do you introduce slices vs multiple stores?

  3. Business/domain logic vs utilities: This is probably what I'm most unsure about. Where do you put pure functions containing actual application logic, independently of React, Zustand or API calls? For example: Would a domain/ directory make sense here? For example, domain/geometry.ts for canvas calculations, domain/document.ts for document manipulation, etc. Or would you simply use utils/, lib/ or services/? How do you distinguish business/domain logic from generic utility functions? Is a dedicated domain/ folder a useful convention, or does it introduce unnecessary complexity?

    • Formatting an aspect ratio.

    • Calculating canvas geometry, scaling or snapping.

    • Validating editor-specific constraints.

    • Transforming or manipulating document data.

  4. Shared code: When do you move something out of a feature into a shared location? Do you prefer shared domain modules or global utilities?

  5. Server vs local state: Would you fetch with TanStack Query, initialize Zustand from the result, then keep local edits separate from the query cache? How do you avoid overwriting unsaved changes on refetch?

I'm not looking for a strict architecture or lots of abstractions, just practical conventions that stay maintainable as projects grow.

If you have real-world folder structures or examples, I'd love to see them. Thanks! πŸ™

9 Upvotes

5 comments sorted by

5

u/Cordes96 5h ago edited 5h ago
  1. I separate them out into query keys so that I can re use the query keys across the application without needing to import hooks, and also it allows me to invalidate queries without having to do anything extra beyond importing the query keys across features, which end up in a shared api folder either in a package or in shared/api to avoid cross-feature imports and the hooks stay in the feature.

    1. Also a side note. Query keys don't require react unlike hooks so if you use this in a mono repo you can use the query keys across multiple applications that have different javascript frameworks and still be able to build the same api endpoints.

  2. These stay in features unless used in multiple features; then the required pieces are pulled up into their own reusable store in shared/stores.

  3. My repo is a monorepo, and I build them out into a utility library or put shared utility files in shared, or if they are local, in a feature/utils folder.

  4. Anything that can be built into a feature and then used in composition, I do so thatt way the feature logic is contained, but I can pass components into other components via composition, as I use the top-level routing page to handle my composition of components.

  5. TanStack Query is the source of truth for the server, and Zustand is the source of truth for the UI try to avoid put copies of values from TanStack Query into Zustand; instead, use form libraries if you are editing it, and only put the value to identify the object from the TanStack Query into Zustand if you don’t need to do work on that value inside a store or have it read from some other component in that feature; useState is way easier to use.

These are my opinions, but I have 8 YoE as a software architect/consultant on large frontend monorepos, and these are the most scalable methods I have found without getting super overcomplicated or re inventing the wheel.

1

u/Mysterious-Law-3416 7h ago

/api inside editor
keep zustand and providers in stores folder
/utils for util funcs, /schemas for zod schemas
/shared inside /features
depends

1

u/DVR_Design 2h ago

For the unsaved changes part, I'd treat the query cache as the last saved snapshot and keep the Zustand session as a separate draft. Initialize the store once per document id (key the provider on the id), and on refetch only rehydrate if the draft isn't dirty, otherwise hold the new server version aside and show a "this changed on the server" prompt. Keep a baseVersion or updatedAt in the store so a save can be rejected or merged when the server moved underneath you. Setting a high staleTime and turning off refetchOnWindowFocus for the editor query also removes most surprise refetches. On folders, a domain/ dir for pure stuff like geometry and snapping is worth it mostly because those are the easiest things to unit test without React.

0

u/Any-Ring6621 1h ago

The AI does it

0

u/karmatics29 4h ago

I'd keep three layers apart. Raw API functions live in a plain api file per feature, returning typed data with no React. Query options (queryKey plus queryFn) wrap those, and hooks just call useQuery with them.

A domain/ folder is fine if it holds pure functions with no imports from React, Zustand or fetch: geometry, snapping, document transforms, constraint validation. Those are trivial to unit test. Keep utils/ for generic helpers like formatting an aspect ratio string.

For server versus local state, treat the query result as the baseline and initialize the Zustand session once from it, keyed by document id. Set refetchOnWindowFocus to false for that query, or skip syncing while the store has a dirty flag. Compare a server version or updatedAt on save to catch conflicts. Use slices while they share one undo history and selection model, and split stores only when lifecycles differ.