So in my project I have several instances where I have to fetch data from firebase that would change rather frequently.
I have decided to use custom hooks to fetch the data and reuse them in different components if need be.
For example, I might need to retrieve the data for a single task and other times I need to fetch data for an array of tasks.
Currently I have implemented the following to retrieve a single task:
const useTask = (taskId: string) => {
const [taskData, setTaskData] = useState<TaskWithId>({} as TaskWithId)
useEffect(() => {
const taskCollectionRef = collection(db, 'tasks')
const qTask = query(taskCollectionRef, where(documentId(), '==', taskId))
const unsubscribe = onSnapshot(qTask, querySnapshot => {
querySnapshot.forEach(doc => {
setTaskData({
id: doc.id,
title: doc.data().title,
createdBy: doc.data().createdBy,
description: doc.data().description,
assignedTo: doc.data().assignedTo,
parent: doc.data().parent,
createdAt: doc.data().createdAt,
modifiedAt: doc.data().modifiedAt,
dueDate: doc.data().dueDate,
completed: doc.data().completed,
})
})
})
return unsubscribe
}, [taskId])
return taskData
}
export default useTask
For multiple tasks, I am using another custom hook as follows:
const useTaskArr = (taskArr: string[]) => {
const [taskArrData, setTaskArrData] = useState<TaskWithId[]>([])
useEffect(() => {
if (taskArr.length == 0) {
setTaskArrData([])
return
}
const taskCollectionRef = collection(db, 'tasks')
const qTask = query(taskCollectionRef, where(documentId(), 'in', taskArr))
const unsubscribe = onSnapshot(qTask, querySnapshot => {
setTaskArrData(
querySnapshot.docs.map<TaskWithId>(doc => ({
id: doc.id,
title: doc.data().title,
createdBy: doc.data().createdBy,
description: doc.data().description,
assignedTo: doc.data().assignedTo,
parent: doc.data().parent,
createdAt: doc.data().createdAt,
modifiedAt: doc.data().modifiedAt,
dueDate: doc.data().dueDate,
completed: doc.data().completed,
}))
)
})
return unsubscribe
}, [taskArr])
return taskArrData
}
export default useTaskArr
As you can see, the query is different if it is a single task or an array and the type of the state is also different.
How could I merge both in a single custom hook and pass whether I would need to retrieve an array of tasks or a single one?
Related
So I'm making a kanban board style task manager using react and react query. My current implementation of the data fetching is like the following:
const { data } = useQuery('listCollection', getListCollection)
and the content of data is something like this:
// data
{
listOrder: number[]
lists: IList[]
}
interface IList {
id: number
title: string
todoOrder: number[]
todos: ITodo[]
}
interface ITodo {
id: number
text: string
checked: boolean
}
So basically a list collection contains multiple lists and each lists contain multiple todos.
Now, I want this application to do optimistic update on each mutation (add a new todo, delete, check a todo, etc).
Here is my current implementation of optimistic update when toggling a todo check:
const mutation = useMutation(
(data: { todoId: number; checked: boolean }) =>
editTodo(data),
{
onMutate: async (data) => {
await queryClient.cancelQueries('listCollection')
const previousState = queryClient.getQueryData('listCollection')
queryClient.setQueryData('listCollection', (prev: any) => ({
...prev,
lists: prev.lists.map((list: IList) =>
list.todoOrder.includes(data.todoId)
? {
...list,
todos: list.todos.map((todo) =>
todo.id === data.todoId
? { ...todo, checked: data.checked }
: todo,
),
}
: list,
),
}))
return { previousState }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(parent, context?.previousState)
},
onSuccess: () => queryClient.invalidateQueries(parent),
},
)
As you can see, that's overly complicated. How should I approach this?
The best way to update the deeply nested data in react query is by using "Immer" library. It is very light weight and it uses proxies to change the reference of only updated data, and reducing the cost of rendering for non-updated data.
import produce from "immer";
const mutation = useMutation(
(data: { todoId: number; checked: boolean }) =>
editTodo(data),
{
onMutate: async (data) => {
await queryClient.cancelQueries('listCollection')
const previousState = queryClient.getQueryData('listCollection')
const updData = produce(previousState, (draftData) => {
// Destructing the draftstate of data.
let {lists} = draftData;
lists = lists.map((list: IList) => {
// Mapping through lists and checking if id present in todoOrder and todo.
if(list.todoOrder.includes(data.todoId) && list.todo[data.id]){
list.todo[data.id].checked = data.checked;
}
return list.
}
// Updating the draftstate with the modified values
draftData.lists = lists;
}
// Setting query data.
queryClient.setQueryData("listCollection", updData);
return { previousState }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(parent, context?.previousState)
},
onSuccess: () => queryClient.invalidateQueries(parent),
},
)
This will solve your case. You can modify the listOrder if needed just the way I updates lists.
My data is stored in two separate tables; "posts" and "profiles". Each User object comes from the "profiles" table but also has a list posts which is not a column in "profiles". Because of this, I need to fetch the posts first, then their corresponding users, then add each post to their User object based on "uid". My function below works for most of that but every user has an empty posts list, even when there should be posts.
const [posts, setPosts] = useState<Array<Post>>([]);
const [profiles, setProfiles] = useState<Array<User>>([]);
useEffect(() => {
async function fetchData() {
const { data: postsData } = await supabase.from("posts").select("*");
const postUids = postsData!.map((post) => post.uid);
const { data: profilesData } = await supabase
.from("profiles")
.select("*")
.in("uid", postUids);
setPosts(postsData!.map((post) => new Post(post)));
const profiles = profilesData!.map((userData: any) => {
const userPosts: Array<Post> = posts.filter(
(post) => post.uid === userData.uid
);
console.log("User posts: " + userPosts);
const user = new User({ ...userData, posts: userPosts });
// user.posts = [...user.posts, ...userPosts];
console.log(user);
return user;
});
setProfiles((prevUsers) => [...prevUsers, ...profiles]);
console.log(profiles);
}
fetchData();
}, []);
console.log(posts);
console.log(profiles);
Example of postsData:
[{
"caption":"Caption",
"date":"1669244422569",
"imageUrls":[
"https://cdn.pixabay.com/photo/2020/05/04/16/05/mckenzie-river-5129717__480.jpg"
],
"location":{
"latitude":150,
"locationInfo":"City, State",
"longitude":-150
},
"postId":"1669244407166",
"uid":"daf6b8be-7cd0-4341-89d7-07879b207087"
}]
Post object:
export default class Post {
imageUrls: Array<string>;
postId: string;
uid: string;
caption: string;
location: Location;
date: number;
constructor(post: any) {
this.imageUrls = post.imageUrls;
this.postId = post.postId;
this.uid = post.uid;
this.caption = post.caption;
this.location = post.location;
this.date = post.date;
}
}
Example of profilesData:
{
"blockedUsers":[],
"displayName":"name",
"photoURL":"https://cdn.pixabay.com/photo/2020/05/04/16/05/mckenzie-river-5129717__480.jpg",
"uid":"daf6b8be-7cd0-4341-89d7-07879b207087",
"verified":false
}
User object:
export default class User {
uid: string;
blockedUsers: Array<string>;
posts: Array<Post>;
photoURL: string;
displayName: string;
verified: boolean;
constructor(user: any) {
this.uid = user.uid;
this.blockedUsers = user.blockedUsers;
this.posts = user.posts;
this.photoURL = user.photoURL;
this.displayName = user.displayName;
this.verified = user.verified;
}
}
Not entirely sure why you are not getting any posts data, might be due to how your RLS is configured, but there is a better way to query your data.
You can query posts and profiles at the same time like this:
const { data, error } = await supabase.from("profiles").select("*, posts(*)");
This way, you don't have to do another query to retrieve the profiles separately, and you also don't have to loop through the retrieved objects to modify them.
How can I get a list of cars for a customer
clients:
w21rffa3:
name: Johny
phone: 123123
cars:
fn1jnr12:
brand: AUDi
model: a6
number: 24f1
dsdasgf122:
brand: AUDi
model: a3
number: 62s14
My code
const ref = firestore().collection('clients');
const [clientsList, setClientsList] = useState([]);
useEffect(() => {
return ref.onSnapshot(clientsSnapshot => {
const clients = [];
const cars = [];
clientsSnapshot.forEach(client => {
const carsRef = ref.doc(client.id).collection('cars').onSnapshot(carsSnapshot => {
carsSnapshot.forEach(car => {
if (car.data().brand.length > 0) {
const {
brand,
model,
number
} = car.data();
cars.push({
id: car.id,
brand,
model,
number,
});
}
});
//Good result`
console.log('After forEach: ', cars);
});
//Bad result
console.log('After snapshot: ', cars);
const {
name,
phone
} = client.data();
clients.push({
id: client.id,
name,
phone,
cars: cars,
});
});
setClientsList(clients);
});
}, []);
cars list for customers
The error you facing is due to misuse/misunderstanding of how async/callback-based functions works. As I said in my comment - good result and bad result - bad result scripts are executed before good result due to onSnapshot is async, and you pass a callback function to it, which will be executed when data is available from firebase, so a bit "later" than the rest of the code.
Now about what can be done. The code is a bit tricky and I didnt really test it, so if anything - please, let me know.
const [clientsList, setClientsList] = useState([]);
useEffect(() => {
let carsUnsubscribeFns = [];
const clientUnsubscribeFn = ref.onSnapshot((clientsSnapshot) => {
// Reset everything and stop previously created listeners for Cars
setClientsList([]);
carsUnsubscribeFns.forEach((x) => x());
carsUnsubscribeFns = [];
clientsSnapshot.forEach((c) => {
const { name, phone } = c.data();
const client = { id: c.id, name, phone };
// In case you dont want to use optional chaining,
// remove the const client = ... line above
// and uncomment the line below
// but optional chaining is prefered anyway
// const client = { id: c.id, name, phone, cars: [] };
const carsUnsubscribeFn = ref
.doc(client.id)
.collection("cars")
.onSnapshot((carsSnapshot) => {
// Mutate the Client object directly
client.cars = carsSnapshot.docs
.map((x) => ({ id: x.id, ...x.data() }))
.filter((x) => x.brand?.length > 0);
// mark ClientsList as updated to force a rerender
// due to we mutated one of the entities inside
setClientsList((curr) => [...curr]);
});
carsUnsubscribeFns.push(carsUnsubscribeFn);
setClientsList((curr) => {
curr.push(client);
return [...curr];
});
});
// clean-up function returned from hook to stop all the listeners
return () => {
[clientUnsubscribeFn, ...carsUnsubscribeFns].forEach((x) => x());
};
});
}, []);
I have a very basic prototype of app that allows to book a seat. User selects the seat/seats, clicks book, patch request with available: false is sent to the fake api (json-server) with React Query, library invalidates the request and immediately shows response from the server.
Database structure looks like this:
{
"hallA": [
{
"id": 1,
"seat": 1,
"available": false
},
{
"id": 2,
"seat": 2,
"available": true
},
{
"id": 3,
"seat": 3,
"available": false
}
]
}
and the logic for selecting, booking seats looks like this:
const App = () => {
const { data, isLoading } = useGetHallLayout("hallA");
const [selected, setSelected] = useState<
{ id: number; seat: number; available: boolean }[]
>([]);
const handleSelect = useCallback(
(seat: { id: number; seat: number; available: boolean }) => {
const itemIdx = selected.findIndex((element) => element.id === seat.id);
if (itemIdx === -1) {
setSelected((prevState) => [
...prevState,
{ id: seat.id, seat: seat.seat, available: !seat.available },
]);
} else {
setSelected((prevState) =>
prevState.filter((element) => element.id !== seat.id)
);
}
},
[selected]
);
const takeSeat = useTakeSeat({
onSuccess: () => {
useGetHallLayout.invalidate();
},
});
const sendRequest = useCallback(() => {
selected.forEach((item) =>
takeSeat.mutateAsync({ id: item.id, hall: "hallA" })
);
setSelected([]);
}, [selected, takeSeat]);
return (
<>
{!isLoading && (
<ConcertHall
layout={data}
onSeatSelect={handleSelect}
activeSeats={selected}
/>
)}
<button disabled={isLoading} onClick={sendRequest}>
Take selected
</button>
</>
);
};
Queries look like this:
export const useGetHallLayout = (hall: string) => {
const { data, isLoading } = useQuery(["hall"], () =>
axios.get(`http://localhost:3000/${hall}`).then((res) => res.data)
);
return { data, isLoading };
};
export const useTakeSeat = (options?: UseMutationOptions<unknown, any, any>) =>
useMutation(
(data: { hall: string; id: number }) =>
axios.patch(`http://localhost:3000/${data.hall}/${data.id}`, {
available: false,
}),
{
...options,
}
);
useGetHallLayout.invalidate = () => {
return queryClient.invalidateQueries("hall");
};
The problem of the above code is that I perform very expensive operation of updating each id in a for each loop (to available: false) and query invalidates it after each change not once all of them are updated.
The question is: is there any better way to do this taking into account the limitations of json-server? Any batch update instead of sending request to each and every id seperately? Maybe some changes in a logic?
Thanks in advance
You can certainly make one mutation that fires of multiple requests, and returns the result with Promise.all or Promise.allSettled. Something like:
useMutation((seats) => {
return Promise.allSettled(seats.map((seat) => axios.patch(...))
})
then, you would have one "lifecycle" (loading / error / success) for all queries together, and onSuccess will only be called once.
Another gotcha I'm seeing is that you'd really want the hall string to be part of the query key:
- useQuery(["hall"], () =>
+ useQuery(["hall", hall], () =>
Im playing around with recoil for the first time and cant figure out how I can read all elements from an atomFamily. Let's say I have an app where a user can add meals:
export const meals = atomFamily({
key: "meals",
default: {}
});
And I can initialize a meal as follows:
const [meal, setMeal] = useRecoilState(meals("bananas"));
const bananas = setMeal({name: "bananas", price: 5});
How can I read all items which have been added to this atomFamily?
You have to track all ids of the atomFamily to get all members of the family.
Keep in mind that this is not really a list, more like a map.
Something like this should get you going.
// atomFamily
const meals = atomFamily({
key: "meals",
default: {}
});
const mealIds = atom({
key: "mealsIds",
default: []
});
When creating a new objects inside the family you also have to update the mealIds atom.
I usually use a useRecoilCallback hook to sync this together
const createMeal = useRecoilCallback(({ set }) => (mealId, price) => {
set(mealIds, currVal => [...currVal, mealId]);
set(meals(mealId), {name: mealId, price});
}, []);
This way you can create a meal by calling:
createMeal("bananas", 5);
And get all ids via:
const ids = useRecoilValue(mealIds);
Instead of using useRecoilCallback you can abstract it with selectorFamily.
// atomFamily
const mealsAtom = atomFamily({
key: "meals",
default: {}
});
const mealIds = atom({
key: "mealsIds",
default: []
});
// abstraction
const meals = selectorFamily({
key: "meals-access",
get: (id) => ({ get }) => {
const atom = get(mealsAtom(id));
return atom;
},
set: (id) => ({set}, meal) => {
set(mealsAtom(id), meal);
set(mealIds (id), prev => [...prev, meal.id)]);
}
});
Further more, in case you would like to support reset you can use the following code:
// atomFamily
const mealsAtom = atomFamily({
key: "meals",
default: {}
});
const mealIds = atom({
key: "mealsIds",
default: []
});
// abstraction
const meals = selectorFamily({
key: "meals-access",
get: (id) => ({ get }) => {
const atom = get(mealsAtom(id));
return atom;
},
set: (id) => ({set, reset}, meal) => {
if(meal instanceof DefaultValue) {
// DefaultValue means reset context
reset(mealsAtom(id));
reset(mealIds (id));
return;
}
set(mealsAtom(id), meal);
set(mealIds (id), prev => [...prev, meal.id)]);
}
});
If you're using Typescript you can make it more elegant by using the following guard.
import { DefaultValue } from 'recoil';
export const guardRecoilDefaultValue = (
candidate: unknown
): candidate is DefaultValue => {
if (candidate instanceof DefaultValue) return true;
return false;
};
Using this guard with Typescript will look something like:
// atomFamily
const mealsAtom = atomFamily<IMeal, number>({
key: "meals",
default: {}
});
const mealIds = atom<number[]>({
key: "mealsIds",
default: []
});
// abstraction
const meals = selectorFamily<IMeal, number>({
key: "meals-access",
get: (id) => ({ get }) => {
const atom = get(mealsAtom(id));
return atom;
},
set: (id) => ({set, reset}, meal) => {
if (guardRecoilDefaultValue(meal)) {
// DefaultValue means reset context
reset(mealsAtom(id));
reset(mealIds (id));
return;
}
// from this line you got IMeal (not IMeal | DefaultValue)
set(mealsAtom(id), meal);
set(mealIds (id), prev => [...prev, meal.id)]);
}
});
You can use an atom to track the ids of each atom in the atomFamily. Then use a selectorFamily or a custom function to update the atom with the list of ids when a new atom is added or deleted from the atomFamily. Then, the atom with the list of ids can be used to extract each of the atoms by their id from the selectorFamily.
// File for managing state
//Atom Family
export const mealsAtom = atomFamily({
key: "meals",
default: {},
});
//Atom ids list
export const mealsIds = atom({
key: "mealsIds",
default: [],
});
This is how the selectorFamily looks like:
// File for managing state
export const mealsSelector = selectorFamily({
key: "mealsSelector",
get: (mealId) => ({get}) => {
return get(meals(mealId));
},
set: (mealId) => ({set, reset}, newMeal) => {
// if 'newMeal' is an instance of Default value,
// the 'set' method will delete the atom from the atomFamily.
if (newMeal instanceof DefaultValue) {
// reset method deletes the atom from atomFamily. Then update ids list.
reset(mealsAtom(mealId));
set(mealsIds, (prevValue) => prevValue.filter((id) => id !== mealId));
} else {
// creates the atom and update the ids list
set(mealsAtom(mealId), newMeal);
set(mealsIds, (prev) => [...prev, mealId]);
}
},
});
Now, how do you use all this?
Create a meal:
In this case i'm using current timestamp as the atom id with Math.random()
// Component to consume state
import {mealsSelector} from "your/path";
import {useSetRecoilState} from "recoil";
const setMeal = useSetRecoilState(mealsSelector(Math.random()));
setMeal({
name: "banana",
price: 5,
});
Delete a meal:
// Component to consume state
import {mealsSelector} from "your/path";
import {DefaultValue, useSetRecoilState} from "recoil";
const setMeal = useSetRecoilState(mealsSelector(mealId));
setMeal(new DefaultValue());
Get all atoms from atomFamily:
Loop the list of ids and render Meals components that receive the id as props and use it to get the state for each atom.
// Component to consume state, parent of Meals component
import {mealsIds} from "your/path";
import {useRecoilValue} from "recoil";
const mealIdsList = useRecoilValue(mealsIds);
//Inside the return function:
return(
{mealIdsList.slice()
.map((mealId) => (
<MealComponent
key={mealId}
id={mealId}
/>
))}
);
// Meal component to consume state
import {mealsSelector} from "your/path";
import {useRecoilValue} from "recoil";
const meal = useRecoilValue(mealsSelector(props.id));
Then, you have a list of components for Meals, each with their own state from the atomFamily.
Here is how I have it working on my current project:
(For context this is a dynamic form created from an array of field option objects. The form values are submitted via a graphql mutation so we only want the minimal set of changes made. The form is therefore built up as the user edits fields)
import { atom, atomFamily, DefaultValue, selectorFamily } from 'recoil';
type PossibleFormValue = string | null | undefined;
export const fieldStateAtom = atomFamily<PossibleFormValue, string>({
key: 'fieldState',
default: undefined,
});
export const fieldIdsAtom = atom<string[]>({
key: 'fieldIds',
default: [],
});
export const fieldStateSelector = selectorFamily<PossibleFormValue, string>({
key: 'fieldStateSelector',
get: (fieldId) => ({ get }) => get(fieldStateAtom(fieldId)),
set: (fieldId) => ({ set, get }, fieldValue) => {
set(fieldStateAtom(fieldId), fieldValue);
const fieldIds = get(fieldIdsAtom);
if (!fieldIds.includes(fieldId)) {
set(fieldIdsAtom, (prev) => [...prev, fieldId]);
}
},
});
export const formStateSelector = selectorFamily<
Record<string, PossibleFormValue>,
string[]
>({
key: 'formStateSelector',
get: (fieldIds) => ({ get }) => {
return fieldIds.reduce<Record<string, PossibleFormValue>>(
(result, fieldId) => {
const fieldValue = get(fieldStateAtom(fieldId));
return {
...result,
[fieldId]: fieldValue,
};
},
{},
);
},
set: (fieldIds) => ({ get, set, reset }, newValue) => {
if (newValue instanceof DefaultValue) {
reset(fieldIdsAtom);
const fieldIds = get(fieldIdsAtom);
fieldIds.forEach((fieldId) => reset(fieldStateAtom(fieldId)));
} else {
set(fieldIdsAtom, Object.keys(newValue));
fieldIds.forEach((fieldId) => {
set(fieldStateAtom(fieldId), newValue[fieldId]);
});
}
},
});
The atoms are selectors are used in 3 places in the app:
In the field component:
...
const localValue = useRecoilValue(fieldStateAtom(fieldId));
const setFieldValue = useSetRecoilState(fieldStateSelector(fieldId));
...
In the save-handling component (although this could be simpler in a form with an explicit submit button):
...
const fieldIds = useRecoilValue(fieldIdsAtom);
const formState = useRecoilValue(formStateSelector(fieldIds));
...
And in another component that handles form actions, including form reset:
...
const resetFormState = useResetRecoilState(formStateSelector([]));
...
const handleDiscard = React.useCallback(() => {
...
resetFormState();
...
}, [..., resetFormState]);