qumu
TypeScript icon, indicating that this package has built-in type declarations

0.0.2 • Public • Published

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")
);

Package Sidebar

Install

npm i qumu

Weekly Downloads

1

Version

0.0.2

License

ISC

Unpacked Size

109 kB

Total Files

8

Last publish

Collaborators

  • linq2js