T
TanStack2w ago
flat-fuchsia

Creating domain objects

What would be the correct pattern to create domain objects that need multiple API requests? In ngrx you would create a selector like this
userWithPosts = createSelector(..., (user, posts) => {
const postsForUser = posts.map(post => post.belongsToUserId === user.id)

return {...user, posts: postsForUser}
})
userWithPosts = createSelector(..., (user, posts) => {
const postsForUser = posts.map(post => post.belongsToUserId === user.id)

return {...user, posts: postsForUser}
})
This can be much more complicated as you can merge foreign objects into a user that are much deeper nested than in this example. A post can have a property likedByUsers and you can also merge that into the selector too. What would be a tanstack query setup?
41 Replies
extended-salmon
extended-salmon2w ago
Do you really need to merge it into a user object though? I remember a React project years ago with a very granular (and annoying) backend api as the micro service architecture wasn’t abstracted away from the frontend. First I needed to get an id then for other fields and relations other dependent queries. I just kept all of these queries separate, where needed as simple dependent queries and relied on request deduplication and prefetching. There were data grids where each page could have a lot of queries - a single column could have many instances of the same query to fetch a name field for an id for example. Because of request deduplication that doesn't matter. Even though the backend was quite sluggish the frontend felt very snappy. Only merged such data in forms sometimes. If data from multiple requests needs to be merged in a single object I'd definitely use RxJS to compose it into a single observable and pass that to queryFn. Note that this approach will result in a single cache entry for the combined data.
Dependent Queries | TanStack Query Angular Docs
injectQuery dependent Query Dependent (or serial) queries depend on previous ones to finish before they can execute. To achieve this, it's as easy as using the enabled option to tell a query when it i...
extended-salmon
extended-salmon2w ago
BTW - in the Angular community there's still a strong sense of a need to separate "smart" and "dumb" components. It depends, but I don't necessarily agree with that being always the best pattern. It can make code substantially more complex.
flat-fuchsia
flat-fuchsiaOP2w ago
Thanks a lot for your answer. I have a few remarks. I try to keep it short and concise. 1. Creating one domain object enables us to create the object once and then pass it down to other components that then can work with it. It sure has downsides, e.g. less granular change detection. 2. Let's assume we have a User and the user has Posts (nested relations can go much deeper as you already stated). Keeping two separate objects for User and Posts_for_user would make the setup simpler as it doesn't require merging, I agree. Do you "cross invalidate" queries from UserService to PostService and vice versa? Or does every mutation inside a Domain service invalidate their "own" queries?
// get posts
const posts = injectQuery(() => this.postService.getPostsForUser(user))

// add post to user
const mutation = injectMutation(() => this.userService.addPostToUser())
addPostToUser(posts) {
this.mutation.mutate(this.user, posts)
// this requires to taking care of invalidating the posts from within the `userService` right?
}
// get posts
const posts = injectQuery(() => this.postService.getPostsForUser(user))

// add post to user
const mutation = injectMutation(() => this.userService.addPostToUser())
addPostToUser(posts) {
this.mutation.mutate(this.user, posts)
// this requires to taking care of invalidating the posts from within the `userService` right?
}
3. Caching the combined query (using rxjs) under one cache key seems a bit impractical as I would always invalidate the entire object and refetch every entity in the merged object.
extended-salmon
extended-salmon2w ago
1. Is classic container components and why it often complicates applications. Everything needs to pass and be mapped through a container component. In my opinion it's outdated but often "not done" to say that. Unless someone requires you to follow that pattern you could consider if just getting whatever data you need in a component would simplify the architecture (within reason - a simple component can get some data from a parent. You can still organize queries in services of course - I'd recommend queryOptions for that. 2. If you structure the query keys well it's very easy to invalidate the queries needed. For example an array with a user id. Just invalidate everything with that user id. It's usually better to invalidate too much than too little. 3. Exactly: it's often much better to have separated queries. But container components make that hard and you may quickly need a ton of data mapping and merging code. If it helps: having a query in a component is just injecting a data dependency. Even more when queryOptions keeps the fetching logic outside of the component. The component just declares that it needs the data and the service with QueryOptions and TanStack Query automate the whole fetching process.
flat-fuchsia
flat-fuchsiaOP2w ago
Ok, I can see what you mean. Still, though. I can't get my head wrapped around the "cross service" invalidation of queries. That seems very confusing. I can have many queries that are affected by this mutation. E.g.
// UserService
addPostToUser(posts) {
return mutationOptions(() => ({
mutationFn: async ({ userId, update }: { userId: ID; posts: Post[] }) => {
return this.apiService.updateUserAsync<T>(userId, posts);
},
onSuccess: (variables) => {
// post queries and in PostService
this.queryClient.invalidateQueries({ queryKey: ["posts", variables.userId] });
}
}))
}
// UserService
addPostToUser(posts) {
return mutationOptions(() => ({
mutationFn: async ({ userId, update }: { userId: ID; posts: Post[] }) => {
return this.apiService.updateUserAsync<T>(userId, posts);
},
onSuccess: (variables) => {
// post queries and in PostService
this.queryClient.invalidateQueries({ queryKey: ["posts", variables.userId] });
}
}))
}
extended-salmon
extended-salmon2w ago
I don't know your application requirements but I can imagine it's all related to that user? You could invalidate at user level, post level etc Structure the query keys according to those needs So if a user has blogs posts and comments below that blog post, invalidating the user could invalidate all of those and cause a refresh of the data if all of these queries contain the user id in their key
flat-fuchsia
flat-fuchsiaOP2w ago
Yes, user can have many relations in this example. If I understood correctly every relation of that user gets its own query (with api call), so it needs to be invalidated by the user service somehow, when it updates ok, that means, in that user service we need to know all the other queries that might be affected by the update of the user, right?
extended-salmon
extended-salmon2w ago
Those queries probably already require a user id? So then it's natural to include it in the query key too Even if the backend API doesn't require it you can still include it in the query key
flat-fuchsia
flat-fuchsiaOP2w ago
Yes, true.
// PostService
// get posts for user
this.queryOptions({
queryKey: [POST_KEY, userId()],
queryFn: () => this.apiService.fetchManyPostsByUserIdAsync(userId()),
}),

// CoworkerService
// get coworkers for user
this.queryOptions({
queryKey: [COWORKER_KEY, userId()],
queryFn: () => this.apiService.fetchManyCoworkersByUserIdAsync(userId()),
}),
// PostService
// get posts for user
this.queryOptions({
queryKey: [POST_KEY, userId()],
queryFn: () => this.apiService.fetchManyPostsByUserIdAsync(userId()),
}),

// CoworkerService
// get coworkers for user
this.queryOptions({
queryKey: [COWORKER_KEY, userId()],
queryFn: () => this.apiService.fetchManyCoworkersByUserIdAsync(userId()),
}),
// UserService
addPostToUser(posts) {
return mutationOptions(() => ({
mutationFn: async ({ userId, update }: { userId: ID; posts: Post[] }) => { ... },
onSuccess: (variables) => {
this.queryClient.invalidateQueries({ queryKey: [POST_KEY, variables.userId] });
this.queryClient.invalidateQueries({ queryKey: [COWORKER_KEY, variables.userId] });
// ... probably others
// I would need to know all queries throughout the application that rely on user to invalidate them manually here
}
}))
}
// UserService
addPostToUser(posts) {
return mutationOptions(() => ({
mutationFn: async ({ userId, update }: { userId: ID; posts: Post[] }) => { ... },
onSuccess: (variables) => {
this.queryClient.invalidateQueries({ queryKey: [POST_KEY, variables.userId] });
this.queryClient.invalidateQueries({ queryKey: [COWORKER_KEY, variables.userId] });
// ... probably others
// I would need to know all queries throughout the application that rely on user to invalidate them manually here
}
}))
}
extended-salmon
extended-salmon2w ago
Just add some information to be able to make a distinction between coworker and post queryKey: [COWORKER_KEY, userId()], queryKey: [POST_KEY, userId()], It looks like these could be the same now so you cannot invalidate just posts or just coworkers and if a coworker and a post would have the same id they would overwrite each others state
flat-fuchsia
flat-fuchsiaOP2w ago
Those queries need separate api requests so I thought they should get their own query. They both rely on a user id though.
extended-salmon
extended-salmon2w ago
Yes they can have their own query but it looks like they share a query key E.g. if you would have both a post and a coworker with id 1
flat-fuchsia
flat-fuchsiaOP2w ago
Yes, true. What would the invalidateQueries call look like?
extended-salmon
extended-salmon2w ago
Your code example looks correct but consider the queryKey structure
flat-fuchsia
flat-fuchsiaOP2w ago
I'm not sure what you mean. Should I use a different key at index 0?
extended-salmon
extended-salmon2w ago
I don't know what goes into COWORKER_KEY. If it's a number you have overlapping query keys between coworker and post
flat-fuchsia
flat-fuchsiaOP2w ago
Ah, COWORKER_KEY = "coworker"; POST_KEY = "post" Just an identifier for the entity
extended-salmon
extended-salmon2w ago
Right, yeah that would work But you'll also need the actual id for post or coworker right?
flat-fuchsia
flat-fuchsiaOP2w ago
In this case I need the id of the user. So both updateUser and updatePost should trigger a refetch of
this.queryOptions({
queryKey: [POST_KEY, userId()],
queryFn: () => this.apiService.fetchManyPostsByUserIdAsync(userId()),
})
this.queryOptions({
queryKey: [POST_KEY, userId()],
queryFn: () => this.apiService.fetchManyPostsByUserIdAsync(userId()),
})
extended-salmon
extended-salmon2w ago
I'd reverse it though: first userId then post Then you can do: this.queryClient.invalidateQueries({ queryKey: [userId() ] }) Both coworkers and posts would be invalidated
flat-fuchsia
flat-fuchsiaOP2w ago
Correct, using UUID that works. Then all queries that depend on userId() need to start their queryKey with userId()
extended-salmon
extended-salmon2w ago
Yes easy peasy and far less code than forcing everything into a single object and a container component
flat-fuchsia
flat-fuchsiaOP2w ago
What about updating that post? The query key of that post query doesn't include the ID of the post as we don't have it upfront If the post with id 123-abc gets updated I need to invalidate the query.
// assuming the fetched post has id 123-abc
this.queryOptions({
queryKey: [POST_KEY, userId()],
queryFn: () => this.apiService.fetchManyPostsByUserIdAsync(userId()),
})
// assuming the fetched post has id 123-abc
this.queryOptions({
queryKey: [POST_KEY, userId()],
queryFn: () => this.apiService.fetchManyPostsByUserIdAsync(userId()),
})
extended-salmon
extended-salmon2w ago
It's multiple posts
flat-fuchsia
flat-fuchsiaOP2w ago
true, bad example
// assuming the fetched boss has id 123-abc
this.queryOptions({
queryKey: [BOSS_KEY, userId()],
queryFn: () => this.apiService.fetchBossByUserIdAsync(userId()),
})
// assuming the fetched boss has id 123-abc
this.queryOptions({
queryKey: [BOSS_KEY, userId()],
queryFn: () => this.apiService.fetchBossByUserIdAsync(userId()),
})
extended-salmon
extended-salmon2w ago
Something like this? queryKey: ['users', userId, 'posts', postId], queryKey: ['users', userId, 'manager', managerId],
flat-fuchsia
flat-fuchsiaOP2w ago
There's no way to invalidate the query by the ID of the fetched entity right?
extended-salmon
extended-salmon2w ago
Can you give an example? I think it's possible as you can include objects in a key
flat-fuchsia
flat-fuchsiaOP2w ago
// assuming the fetched Company has id 123-abc
this.queryOptions({
queryKey: [COMPANY_KEY, userId()],
queryFn: () => this.apiService.fetchCompanyByUserIdAsync(userId()),
})
// assuming the fetched Company has id 123-abc
this.queryOptions({
queryKey: [COMPANY_KEY, userId()],
queryFn: () => this.apiService.fetchCompanyByUserIdAsync(userId()),
})
const company = injectQuery(this.copmanyService.getCompanyByUser(userId()))

const updateCompanyMutation = this.companyService.updateCompany()
updateCompanyById(){
this.updateCompanyMutation.mutate({companyId, update})
// this mutation wonÄt invalidate the query from the top as it updates by `companyId` not by `userId`
}
const company = injectQuery(this.copmanyService.getCompanyByUser(userId()))

const updateCompanyMutation = this.companyService.updateCompany()
updateCompanyById(){
this.updateCompanyMutation.mutate({companyId, update})
// this mutation wonÄt invalidate the query from the top as it updates by `companyId` not by `userId`
}
extended-salmon
extended-salmon2w ago
queryKey: ['users', userId, 'posts', postId, 'comments', commentId] A simple array already allows a lot of control. With the example above you can invalidate everything for a user including the user data itself, their posts and comments, just their posts and comments on posts or just comments on their posts
flat-fuchsia
flat-fuchsiaOP2w ago
Inside this mutation
this.updateCompanyMutation.mutate({companyId, update})
this.updateCompanyMutation.mutate({companyId, update})
I only know that I'm updating a company and the companyId. So I can only invalidate all companies or the one id
extended-salmon
extended-salmon2w ago
You need to reverse the key order in the array You can also use objects but it might be a bit more complex. I've definitely seen code where it makes sense to use objects in keys though.
flat-fuchsia
flat-fuchsiaOP2w ago
But I can't update the query key after requests is finished right? I'd need to do
this.queryOptions({
queryKey: [COMPANY_KEY, userId()],
queryFn: () => this.apiService.fetchCompanyByUserIdAsync(userId()).then(company => {
this.queryClient.addToQueryKey([COMPANY_KEY, userId()], companyId)
}),
})
this.queryOptions({
queryKey: [COMPANY_KEY, userId()],
queryFn: () => this.apiService.fetchCompanyByUserIdAsync(userId()).then(company => {
this.queryClient.addToQueryKey([COMPANY_KEY, userId()], companyId)
}),
})
extended-salmon
extended-salmon2w ago
https://tkdodo.eu/blog/effective-react-query-keys You can find almost anything on Dominik his blog. The principles are usually easy to translate to Angular as I kept the API close to the original React.
Effective React Query Keys
Learn how to structure React Query Keys effectively as your App grows
flat-fuchsia
flat-fuchsiaOP2w ago
thank you
extended-salmon
extended-salmon2w ago
I'm not sure what the purpose of that code is? Generally rely on reactivity, both TanStack Query and signals are great for that Exception is something like a router guard and even that may change with new signal based APIs in Angular
flat-fuchsia
flat-fuchsiaOP2w ago
I'm not sure how to invalidate a query by the id of the fetched entity. This query can only be invalidated by the string "company" or userId()
this.queryOptions({
queryKey: ["company", userId()],
queryFn: () => this.apiService.fetchCompanyByUserIdAsync(userId()),
})
this.queryOptions({
queryKey: ["company", userId()],
queryFn: () => this.apiService.fetchCompanyByUserIdAsync(userId()),
})
How do you invalidate the query in a mutation that updates the company by the companyId?
updateCompany(() => ({
mutationFn: async ({ companyId, update }: { companyId: ID; posts: Post[] }) => { ... },
onSuccess: (variables) => {
this.queryClient.invalidateQueries({ queryKey: [ companyId() ] });
}
}))
updateCompany(() => ({
mutationFn: async ({ companyId, update }: { companyId: ID; posts: Post[] }) => { ... },
onSuccess: (variables) => {
this.queryClient.invalidateQueries({ queryKey: [ companyId() ] });
}
}))
This only invalidates by companyId()
extended-salmon
extended-salmon2w ago
The ID of the fetched entity is usually on the right. If it's included in an object it's also possible to invalidate on that fetchCompanyByUserIdAsync in that case the entity is the company so its id needs to be on the right Or even without company id: [userId(), 'company'] But if you have an ID it's good to include it [userId(), 'company', companyId()] Or [userId(), 'companies', companyId()]
flat-fuchsia
flat-fuchsiaOP2w ago
the problem is I only have the companyId after I get the response from the queryFn
extended-salmon
extended-salmon2w ago
If a user has a single company [userId(), 'company'] is fine
flat-fuchsia
flat-fuchsiaOP2w ago
When I make mutation that updates the company using the companyId as input param I can't invalidate the query by the userId in the onSuccess of the mutation as I don't have the userId in the mutation

Did you find this page helpful?