qumu
v0.0.2
Published
Hooks for async query and mutation
Downloads
13
Maintainers
Readme
QUMU
Hooks for async query and mutation
Installation
$ npm i qumu --save
# or
$ yarn add qumu
Quick Start
import React from "react";
import { render } from "react-dom";
import { useQuery, createProvider } from "qumu";
import { GetPostsApi, AddPostApi } from "../api";
// creating the qumu query provider
const Provider = createProvider();
const App = () => {
// getPosts is a function that use to execute the GetPostsApi
const getPosts = useQuery(GetPostsApi);
const addPost = async () => {
// wait until AddPostApi is done and refetch the getPosts query
await AddPostApi({
id: Math.random(),
userId: 1,
title: "New Post",
body: "New Post",
});
getPosts.refetch();
};
// the query result provides loading and data props
const { loading, data } = getPosts();
return (
<>
<div>
{loading ? "Loading..." : <xmp>{JSON.stringify(data, null, 2)}</xmp>}
</div>
<button onClick={addPost}>Add Post</button>
</>
);
};
render(
<Provider>
<App />
</Provider>,
document.getElementById("root")
);
Examples
Counter App
You can use mutation to modify query cached data
import { useQuery, useMutation } from "qumu";
const CountQuery = () => 0;
// execute query and get data
const useCount = () => useQuery(CountQuery).call().data;
const useIncrease = () =>
// the first argument is which query need to be modified
// the second argument is mutation that retrieves the query object
useMutation(CountQuery, (countQuery) => countQuery.data++);
const App = () => {
const count = useCount();
const increase = useIncrease();
return (
<div>
<div>{count}</div>
<button onClick={() => increase()}>Increase</button>
</div>
);
};
Do we need another state management library for the global state?
Optimistic Update
import { useQuery, useMutation } from "qumu";
import { GetPostsApi, AddPostApi } from "../api";
const App = () => {
const getPosts = useQuery(GetPostsApi);
const addPost = useMutation(GetPostApi, async (getPostsQuery) => {
const post = {
id: Math.random(),
userId: 1,
title: "New Post",
body: "New Post",
};
AddPostApi(post);
getPostsQuery.data = [...getPostsQuery.data, post];
});
// execute query to get the result for rendering
// the query result provides loading and data props
const { loading, data } = getPosts();
return (
<>
<div>
{loading ? "Loading..." : <xmp>{JSON.stringify(data, null, 2)}</xmp>}
</div>
<button onClick={addPost} disabled={loading}>
Add Post
</button>
</>
);
};
Keyed Query
import { useQuery, useMutation } from "qumu";
// query key is string if no query key specified, qumu uses function name as query key
const useCount = () => useQuery("count", () => 0).call().data;
const useIncrease = () =>
useMutation("count", (countQuery) => countQuery.data++);
const App = () => {
const count = useCount();
const increase = useIncrease();
return (
<div>
<div>{count}</div>
<button onClick={() => increase()}>Increase</button>
</div>
);
};
Passing payload to query and mutation
import { useQuery, useMutation } from "qumu";
const CountQuery = ({ value }) => value;
// execute query and get data
const useCount = () => useQuery(CountQuery, { value }).call();
const useIncrease = () =>
// the first argument is which query need to be modified
// the second argument is mutation that retrieves the query object
useMutation(CountQuery, (countQuery, { value = 1 }) => {
countQuery.data += value;
});
const App = () => {
const count = useCount();
const increase = useIncrease();
return (
<div>
<div>{count}</div>
<button onClick={() => increase()}>+1</button>
<button onClick={() => increase({ value: 2 })}>+2</button>
</div>
);
};
Handling async mutation
const useAsyncAction = () =>
useMutation(async (_, payload) => {
// delay in 1s
await new Promise((resolve) => setTimeout(resolve, 1000));
return payload;
});
const App = () => {
const asyncAction = useAsyncAction();
// retrieve mutation result. The result provides: loading, data, error props
// These props are useful for handling async mutating status
const { loading, data, error } = asyncAction.result;
return (
<>
{loading && "Processing..."}
Data: {data}
Error: {error}
<button onClick={() => asyncAction(Math.random())}>Async Action</button>
</>
);
};
Real World Example: Todo App
What you will learn?
- Using qumu for storing global state: editing todo, search term, current filter
- Using useResolver to resolve multiple query results
- Using ErrorBoundary to handle async error and retry async data fetching
- Using Suspense to display loading indicator
- Using memoized resolver to improve performance
- Optimistic add/update/remove
import React, { Suspense, useEffect, useRef } from "react";
import { render } from "react-dom";
import { ErrorBoundary } from "react-error-boundary";
import {
useQuery,
useMutation,
useResolver,
createProvider,
memoize,
} from "qumu";
import axios from "axios";
const api = (url, method = "get", data) =>
axios({
method,
url: `https://sq2rp.sse.codesandbox.io/qumu-todo${url}`,
data,
params: {
// the success response will be delayed in 500ms
delay: 500,
// 50% of requests will be failed, that will cause an client errors and we use ErrorBoundary to handle these errors
fail: 0.5,
},
}).then((res) => res.data);
const Provider = createProvider({
// support suspense for loading query data
suspense: true,
});
const FilterQuery = () => "all";
const TermQuery = () => "";
const SelectedTodoQuery = () => null;
const TodoListQuery = () => api("/", "get");
const TodoSorter = (a, b) =>
a.title > b.title ? 1 : a.title < b.title ? -1 : 0;
const FilteredTodoResolver = memoize((todoList, filter, term) => {
// if search term presents
if (term) {
term = term.toLowerCase();
return todoList
.filter((todo) => (todo.title || "").toLowerCase().indexOf(term) !== -1)
.sort(TodoSorter);
}
// do filtering
return filter === "all"
? todoList.slice().sort(TodoSorter)
: filter === "active"
? todoList.filter((todo) => !todo.completed).sort(TodoSorter)
: todoList.filter((todo) => todo.completed).sort(TodoSorter);
});
const TodoSummaryResolver = memoize({
all: (todoList) => todoList.length,
active: (todoList) => todoList.filter((todo) => !todo.completed).length,
completed: (todoList) => todoList.filter((todo) => todo.completed).length,
});
const useFilteredTodoList = () => {
const filterResult = useFilter();
const termResult = useTerm();
const todoListResult = useTodoList();
return useResolver(
[todoListResult, filterResult, termResult],
FilteredTodoResolver
);
};
const useTodoSummary = () => {
const todoListResult = useTodoList();
return useResolver(todoListResult, TodoSummaryResolver);
};
const useSelectedTodo = () => useQuery(SelectedTodoQuery).call();
const useTodoList = () => useQuery(TodoListQuery).call();
const useFilter = () => useQuery(FilterQuery).call();
const useTerm = () => useQuery(TermQuery).call();
const useUpdateFilter = () =>
useMutation(
FilterQuery,
(filterQuery, { filter }) => (filterQuery.data = filter)
);
const useUpdateTerm = () =>
useMutation(TermQuery, (termQuery, { term }) => (termQuery.data = term));
const useUpdateSelectedTodo = () =>
useMutation(
SelectedTodoQuery,
(selectedTodoQuery, { todo }) => (selectedTodoQuery.data = todo)
);
const useAddTodo = () =>
useMutation(TodoListQuery, (todoListQuery, { data }) => {
// generate random id for new todo
// add $local- prefix for local id
const todo = { ...data, id: "$local-" + Math.random().toString(36) };
// optimistic update
todoListQuery.data = [...todoListQuery.data, todo];
api(`/`, "post", todo)
.then(({ id }) => {
// replace local id with server id
todoListQuery.data = todoListQuery.data.map((x) =>
x === todo ? { ...x, id } : x
);
})
.catch((error) => {
todoListQuery.data = todoListQuery.data.filter((x) => x !== todo);
alert(error.message);
});
});
const useUpdateTodo = () =>
useMutation(TodoListQuery, (todoListQuery, { data }) => {
const prevTodo = todoListQuery.data.find((x) => x.id === data.id);
todoListQuery.data = todoListQuery.data.map((todo) =>
todo.id === data.id ? { ...todo, ...data } : todo
);
api(`/${data.id}`, "put", data)
// restore previous todo data if error
.catch((error) => {
if (
todoListQuery.data.findIndex((todo) => todo.id === data.id) !== -1
) {
todoListQuery.data = todoListQuery.data.map((x) =>
x.id === data.id ? prevTodo : x
);
}
alert(error.message);
});
});
const useRemoveTodo = () =>
useMutation(
[TodoListQuery, SelectedTodoQuery],
([todoListQuery, selectedTodoQuery], { id }) => {
// store removedTodo for later use
const removedTodo = todoListQuery.data.find((x) => x.id === id);
// filter our the removed todo from the list
todoListQuery.data = todoListQuery.data.filter((x) => x.id !== id);
// clear edit form if we are removing a editing todo
if (selectedTodoQuery.data && selectedTodoQuery.data.id === id) {
selectedTodoQuery.data = null;
}
api(`/${id}`, "delete")
// re-added removedTodo if error
.catch((error) => {
todoListQuery.data = [...todoListQuery.data, removedTodo];
alert(error.message);
});
}
);
const FilterItem = ({ type, text, unchecked, num }) => {
const filter = useFilter().data;
const updateFilter = useUpdateFilter();
const updateTerm = useUpdateTerm();
const handleClick = () => {
updateFilter({ filter: type });
updateTerm({ term: "" });
};
return (
<label>
<input
type="radio"
checked={unchecked ? false : filter === type}
readOnly={true}
onClick={handleClick}
/>{" "}
{text} ({num})
</label>
);
};
const FilterBar = () => {
const term = useTerm().data;
const todoSummary = useTodoSummary().data;
const updateTerm = useUpdateTerm();
const filteredTodoList = useFilteredTodoList();
const handleTermChange = (e) => {
updateTerm({ term: e.target.value });
};
return (
<p>
<FilterItem
text="All"
unchecked={term}
type="all"
num={todoSummary.all}
/>{" "}
<FilterItem
text="Completed"
unchecked={term}
type="completed"
num={todoSummary.completed}
/>{" "}
<FilterItem
text="Active"
unchecked={term}
type="active"
num={todoSummary.active}
/>{" "}
<input
placeholder="Enter todo title"
value={term}
onChange={handleTermChange}
/>
{term && <> Found {filteredTodoList.data.length}</>}
</p>
);
};
const TodoForm = () => {
const titleRef = useRef();
const completedRef = useRef();
const selectedTodo = useSelectedTodo().data;
const updateSelectedTodo = useUpdateSelectedTodo();
const addTodo = useAddTodo();
const updateTodo = useUpdateTodo();
const handleSubmit = (e) => {
e.preventDefault();
if (selectedTodo) {
updateTodo({
data: {
id: selectedTodo.id,
title: titleRef.current.value,
completed: completedRef.current.checked,
},
});
// clear selected todo
updateSelectedTodo({ todo: null });
} else {
addTodo({
data: {
title: titleRef.current.value,
completed: completedRef.current.checked,
},
});
// clear inputs
completedRef.current.checked = false;
titleRef.current.value = "";
}
};
useEffect(() => {
titleRef.current.value = selectedTodo ? selectedTodo.title : "";
completedRef.current.checked = selectedTodo
? selectedTodo.completed
: false;
}, [selectedTodo]);
return (
<form onSubmit={handleSubmit}>
<p>
<input type="text" ref={titleRef} placeholder="What need to be done?" />
</p>
<p>
<label>
<input type="checkbox" ref={completedRef} /> Completed
</label>
</p>
<p>
<button type="submit">{selectedTodo ? "Save" : "Add"}</button>
{selectedTodo && (
<button onClick={() => updateSelectedTodo({ todo: null })}>
Cancel
</button>
)}
</p>
</form>
);
};
const TodoList = () => {
const { data, loading } = useFilteredTodoList();
const updateSelectedTodo = useUpdateSelectedTodo();
const removeTodo = useRemoveTodo();
if (loading) return <div>Loading...</div>;
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.completed ? "line-through" : "none" }}
>
{todo.id}: {todo.title}
</span>{" "}
<button onClick={() => updateSelectedTodo({ todo })}>edit</button>{" "}
<button onClick={() => removeTodo({ id: todo.id })}>remove</button>{" "}
</li>
))}
</ul>
);
};
const ErrorFallback = ({ error, resetErrorBoundary }) => {
return (
<div>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
};
const App = () => {
const { refetch } = useTodoList();
return (
/* refetch TodoList if there is any error */
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => refetch()}>
<TodoForm />
<FilterBar />
<TodoList />
</ErrorBoundary>
);
};
render(
<Provider>
<Suspense fallback="Loading...">
<App />
</Suspense>
</Provider>,
document.getElementById("root")
);