Skip to content

feat: useCreateQuery (experimental) #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/docs/Exports/use-create-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
sidebar_position: 4
---

# useCreateQuery

#### **Warning**: This API is experimental and might change.

Lets you use any function that returns a promise in your loader as if it was an RTK useQuery.

```typescript
import {
createLoader,
useCreateQuery,
} from "@ryfylke-react/rtk-query-loader";

const loader = createLoader({
queries: (userId: string) => {
const query = useCreateQuery(async () => {
const res = await fetch(`users/${userId}`);
const json = await res.json();
return json as SomeDataType;
// dependency array
}, [userId]);
return [query] as const;
},
});
```
26 changes: 22 additions & 4 deletions docs/docs/Quick Guide/add-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
sidebar_position: 3
---

# Add queries
# Adding queries

You can now start to add queries to your extended loaders.

The `queries` argument of [createLoader](/Exports/create-loader) is a _hook_, which means that [the rules of hooks](https://reactjs.org/docs/hooks-rules.html) apply. This gives you the super-power of utilizing other hooks inside of your loader.

```tsx title="/src/loaders/userRouteLoader.tsx" {6-10}
import { baseLoader } from "./baseLoader";
// ...
Expand All @@ -21,7 +23,7 @@ export const userRouteLoader = baseLoader.extend({
});
```

As you can see, the `queries` argument is technically a hook. This means that you can run other hooks inside of it.
The hook should return a `readonly array` of the `useQuery` results. This means that in typescript, you need to specify `as const` after the array.

## Accepting arguments

Expand All @@ -42,14 +44,16 @@ export const userRouteLoader = baseLoader.extend({

This argument transforms the consumer's props to the queries argument.

```tsx {7-8}
// Matches any component that accepts a prop `userId` which is a `string`.
```tsx {1-5,8-10}
// This means that any component that has props that extend this
// type can consume the loader using `withLoader`
type UserRouteLoaderProps = Record<string, any> & {
userId: string;
};

export const userRouteLoader = baseLoader.extend({
queriesArg: (props: UserRouteLoaderProps) => props.userId,
// type is now inferred from queriesArg return
queries: (userId) => {
const user = useGetUserQuery(userId);
const posts = useGetPostsByUser(userId);
Expand All @@ -58,3 +62,17 @@ export const userRouteLoader = baseLoader.extend({
},
});
```

A component consuming this loader would pass the argument automatically through this pipeline:

```typescript
// props → queriesArg → queries
loaderArgs.queries(queriesArg(consumerProps));
```

```typescript
<UserRoute userId="1234" />
// → queriesArg({ userId: "1234" })
// → "1234"
// → loader.queries("1234")
```
10 changes: 10 additions & 0 deletions docs/docs/Quick Guide/extend-loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ import { baseLoader } from "./baseLoader";

export const userRouteLoader = baseLoader.extend({});
```

You can pass any argument from [`createLoader`](/Exports/create-loader) into `Loader.extend`.

Its up to you how much you want to separate logic here. Some examples would be...

- Co-locating loaders in a shared folder
- Co-locating loaders in same file as component
- Co-locating loaders in same directory but in a separate file from the component

I personally prefer to keep the loaders close to the component, either in a file besides it or directly in the file itself, and then keep a base loader somewhere else to extend from.
55 changes: 50 additions & 5 deletions docs/docs/problem-solve.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ sidebar_position: 2

# What problem does this solve?

Let's say you have a component that depends on data from more than one query.
Handling the loading and error state of components that depend on external data can be tedious,
especially when there are multiple queries. It is also usually solved inconsistently throughout the project. Your options are essentially

1. Return early (and show loading state)
2. Deal with it in the JSX using conditional rendering, optional chaining, etc...

If you are going for `2`, then you will also have to deal with the type being `T | undefined` in your component methods, which is a bummer.

```tsx
function Component(props){
Expand All @@ -30,10 +36,49 @@ function Component(props){
}
```

The end result is possibly lots of bloated code that has to take into consideration that the values could be undefined, optional chaining, etc...

What if we could instead "join" these queries into one, and then just return early if we are in the initial loading stage. That's basically the approach that rtk-query-loader takes. Some pros include:

- [x] Way less optional chaining in your components
- [x] You get to isolate the data-loading code away from the presentational components
- [x] Better type certainty
- [x] Easy to write re-usable loaders that can be abstracted away from the components
- [x] Way less optional chaining in your components
- [x] Reusability across multiple components
- [x] Extendability
- [x] Transform the output to any format you'd like.

## What does it look like?

```tsx {10-19,22-31}
import {
withLoader,
createLoader,
} from "@ryfylke-react/rtk-query-loader";
import { useParams } from "react-router-dom";
import { useGetUserQuery } from "../api/user";
import { ErrorView } from "../components/ErrorView";

// Create a loader
const userRouteLoader = createLoader({
queries: () => {
const { userId } = useParams();
const userQuery = useGetUserQuery(userId);

return [userQuery] as const; // important
},
onLoading: (props) => <div>Loading...</div>,
onError: (props, error) => <ErrorView error={error} />,
});

// Consume the loader
const UserRoute = withLoader((props: {}, queries) => {
// Queries have successfully loaded
const user = queries[0].data;

return (
<div>
<h2>{user.name}</h2>
</div>
);
}, userRouteLoader);
```

> Get started with our recommended best practises by following the [**Quick guide**](/Quick%20guide) on the next page.
111 changes: 111 additions & 0 deletions src/createQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import * as React from "react";
import * as Types from "./types";

type ReactType = typeof React;
let R = React;

/**
* Creates a query from an async getter function.
*
* ```ts
* const query = useCreateQuery(async () => {
* const response = await fetch("https://example.com");
* return response.json();
* });
* ```
*/
export const useCreateQuery = <T extends unknown>(
getter: Types.CreateQueryGetter<T>,
dependencies?: any[]
): Types.UseQueryResult<T> => {
const [state, dispatch] = R.useReducer(
(
state: Types.UseQueryResult<T>,
action: Types.CreateQueryReducerAction<T>
) => {
switch (action.type) {
case "load":
return {
...state,
isSuccess: false,
isError: false,
isFetching: false,
isLoading: true,
isUninitialized: false,
};
case "fetch":
return {
...state,
isLoading: false,
isSuccess: false,
isError: false,
isFetching: true,
isUninitialized: false,
};
case "success":
return {
...state,
isLoading: false,
isFetching: false,
isError: false,
isUninitialized: false,
isSuccess: true,
data: action.payload.data,
};
case "error":
return {
...state,
isLoading: false,
isSuccess: false,
isFetching: false,
isUninitialized: false,
isError: true,
error: action.payload.error,
};
default:
return state;
}
},
{
isLoading: true,
isSuccess: false,
isError: false,
isFetching: false,
refetch: () => {},
isUninitialized: true,
currentData: undefined,
data: undefined,
error: undefined,
endpointName: "",
fulfilledTimeStamp: 0,
originalArgs: undefined,
requestId: "",
startedTimeStamp: 0,
}
);

R.useEffect(() => {
if (state.data === undefined) {
dispatch({ type: "load" });
} else {
dispatch({ type: "fetch" });
}
const fetchData = async () => {
try {
const data = await getter();
dispatch({ type: "success", payload: { data } });
} catch (error) {
dispatch({ type: "error", payload: { error } });
}
};

fetchData();
}, [...(dependencies ?? [])]);

return state;
};

export const _testCreateUseCreateQuery = (react: any) => {
R = react as ReactType;
return useCreateQuery;
};
23 changes: 23 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,29 @@ export type Loader<
LoaderComponent: Component<CustomLoaderProps>;
};

export type CreateQueryGetter<T extends unknown> =
() => Promise<T>;

export type CreateQueryReducerAction<T extends unknown> =
| {
type: "load";
}
| {
type: "fetch";
}
| {
type: "error";
payload: {
error: unknown;
};
}
| {
type: "success";
payload: {
data: T;
};
};

/************************************************/
/* Legacy/unused, for backwards compatibility */
/************************************************/
Expand Down
64 changes: 63 additions & 1 deletion testing-app/src/tests.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable react-hooks/rules-of-hooks */
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import * as React from "react";
import { createLoader } from "../../src/createLoader";
import { _testCreateUseCreateQuery } from "../../src/createQuery";
import { CustomLoaderProps } from "../../src/types";
import { withLoader } from "../../src/withLoader";
import {
Expand All @@ -23,6 +24,11 @@ import {
waitForElementToBeRemoved,
} from "./utils";

const { useState } = React;

// We do this to avoid two conflicting versions of React
const useCreateQuery = _testCreateUseCreateQuery(React);

describe("aggregateToQuery", () => {
test("It aggregates query status", async () => {
render(<TestAggregateComponent />);
Expand All @@ -33,6 +39,62 @@ describe("aggregateToQuery", () => {
});
});

describe("useCreateQuery", () => {
test("It creates a query", async () => {
const Component = withLoader(
(props, queries) => <div>{queries[0].data.name}</div>,
createLoader({
queries: () => [
useCreateQuery(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 100)
);
return {
name: "charizard",
};
}),
],
onLoading: () => <div>Loading</div>,
})
);
render(<Component />);
expect(screen.getByText("Loading")).toBeVisible();
await waitFor(() =>
expect(screen.getByText("charizard")).toBeVisible()
);
});
test("The query can throw error", async () => {
const Component = withLoader(
(props, queries) => <div>{queries[0].data.name}</div>,
createLoader({
queries: () =>
[
useCreateQuery(async () => {
await new Promise((resolve, reject) =>
setTimeout(
() => reject(new Error("error-message")),
100
)
);
return {
name: "charizard",
};
}),
] as const,
onLoading: () => <div>Loading</div>,
onError: (props, error) => (
<div>{(error as any)?.message}</div>
),
})
);
render(<Component />);
expect(screen.getByText("Loading")).toBeVisible();
await waitFor(() =>
expect(screen.getByText("error-message")).toBeVisible()
);
});
});

describe("withLoader", () => {
test("withLoader properly renders loading state until data is available", async () => {
render(<SimpleLoadedComponent />);
Expand Down