Skip to content

Commit

Permalink
fix: avoid extra renders
Browse files Browse the repository at this point in the history
* avoid setting state same as previous
* fire effect by query state change
* move problematic fetch code to react-query like hook
* memoize SearchBox
  • Loading branch information
adamborowski committed Jan 16, 2023
1 parent 606c1e4 commit 1f91cf0
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 161 deletions.
5 changes: 1 addition & 4 deletions src/common/api/clients/SearchClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
export interface SearchClient<Entity> {
search: (
query: string,
abortController?: AbortController
) => Promise<Entity[]>;
search: (query: string, signal?: AbortSignal) => Promise<Entity[]>;
}
4 changes: 2 additions & 2 deletions src/common/api/clients/fetchSearchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ export const createFetchSearchClient = <ServerType, ClientType>(
adapter: Adapter<ServerType, ClientType>,
fetchImpl: typeof fetch = fetch
): SearchClient<ClientType> => ({
search: async (query, abortController) => {
search: async (query, signal) => {
const response = await fetchImpl(urlFactory(query), {
method: "GET",
headers: {
Authorization: "Bearer " + token,
"Content-Type": "application/json",
},
signal: abortController?.signal,
signal,
});
const json = await response.json();

Expand Down
4 changes: 2 additions & 2 deletions src/common/api/clients/graphqlSearchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const createGraphqlSearchClient = <ServerType, ClientType>(
token: string,
adapter: Adapter<ServerType, ClientType>
): SearchClient<ClientType> => ({
search: async (query, abortController) => {
search: async (query, signal) => {
const gql = {
// TODO inject specific query from external
query: `query ($query: String!) {
Expand Down Expand Up @@ -45,7 +45,7 @@ export const createGraphqlSearchClient = <ServerType, ClientType>(
Authorization: "Bearer " + token,
"Content-Type": "application/json",
},
signal: abortController?.signal,
signal,
body: JSON.stringify(gql),
});
const json = await response.json();
Expand Down
50 changes: 50 additions & 0 deletions src/common/services/useQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { PromiseState } from "../state/usePromiseState";
import CancelablePromise from "cancelable-promise";
import { cancelableWithAbortController } from "../state/cancelablePromise";

export const useQuery = <Data>(
getData: (signal: AbortSignal) => Promise<Data>
) => {
const [state, setState] = useState<PromiseState<Data>>({
type: "pending",
});
const lastPromise = useRef<CancelablePromise | null>(null);
const lastState = useRef(state);

const reload = useCallback(async () => {
const abortController = new AbortController();
lastPromise.current?.cancel();
lastState.current.type !== "pending" && setState({ type: "pending" }); // avoid extra render, read state by ref
try {
lastPromise.current = cancelableWithAbortController(
abortController,
getData(abortController.signal)
);

const fetchedResult = await lastPromise.current;
setState({ type: "loaded", data: fetchedResult });
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") {
// ignore this error, we are already in loading state due to the new fetch
} else {
setState({
type: "error",
message: String(e),
});
}
}
}, [getData]);

useEffect(() => {
void reload();

return () => {
lastPromise.current?.cancel();
};
}, [reload]);

lastState.current = state;

return { state, reload };
};
56 changes: 0 additions & 56 deletions src/common/services/useSearchClient.ts

This file was deleted.

19 changes: 7 additions & 12 deletions src/features/repositories/api/github-graphql/demo.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { FC, useEffect, useState } from "react";
import { PromiseState } from "../../../../common/state/usePromiseState";
import { Repository } from "../../types";
import { FC, useCallback } from "react";
import { repositorySearchClient } from "./repositorySearchClient";
import { ComponentStory } from "@storybook/react";
import { useSearchClient } from "../../../../common/services/useSearchClient";
import { useQuery } from "../../../../common/services/useQuery";

export default {
parameters: {
Expand All @@ -12,14 +10,11 @@ export default {
};

const Template: ComponentStory<FC<{ query: string }>> = ({ query }) => {
const [state, setState] = useState<PromiseState<Repository[]>>({
type: "pending",
});
const { search } = useSearchClient(state, setState, repositorySearchClient);

useEffect(() => {
void search(query);
}, [query, search]);
const getData = useCallback(
(signal: AbortSignal) => repositorySearchClient.search(query, signal),
[query]
);
const { state } = useQuery(getData);

return (
<div>
Expand Down
19 changes: 7 additions & 12 deletions src/features/repositories/api/github-rest/demo.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { FC, useEffect, useState } from "react";
import { PromiseState } from "../../../../common/state/usePromiseState";
import { Repository } from "../../types";
import { FC, useCallback } from "react";
import { repositorySearchClient } from "./repositorySearchClient";
import { ComponentStory } from "@storybook/react";
import { useSearchClient } from "../../../../common/services/useSearchClient";
import { useQuery } from "../../../../common/services/useQuery";

export default {
parameters: {
Expand All @@ -12,14 +10,11 @@ export default {
};

const Template: ComponentStory<FC<{ query: string }>> = ({ query }) => {
const [state, setState] = useState<PromiseState<Repository[]>>({
type: "pending",
});
const { search } = useSearchClient(state, setState, repositorySearchClient);

useEffect(() => {
void search(query);
}, [query, search]);
const getData = useCallback(
(signal: AbortSignal) => repositorySearchClient.search(query, signal),
[query]
);
const { state } = useQuery(getData);

return (
<div>
Expand Down
106 changes: 52 additions & 54 deletions src/features/repositories/components/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { FC, FormEvent, useLayoutEffect, useState } from "react";
import React, { FormEvent, memo, useEffect, useRef, useState } from "react";
import { Button, Flex, Input, useColorModeValue } from "@chakra-ui/react";
import { PromiseStateType } from "../../../common/state/usePromiseState";
import { FormattedMessage } from "react-intl";
Expand All @@ -10,61 +10,59 @@ export interface SearchBoxProps {
onSubmit: (query: string) => void;
}

export const SearchBox: FC<SearchBoxProps> = ({
query,
onSubmit,
searchStateType,
...rest
}) => {
const [currentQuery, setCurrentQuery] = useState(query);
export const SearchBox = memo<SearchBoxProps>(
({ query, onSubmit, searchStateType, ...rest }) => {
const [currentQuery, setCurrentQuery] = useState(query);
const lastOnSubmit = useRef(onSubmit);
lastOnSubmit.current = onSubmit;
useEffect(() => {
setCurrentQuery(query);
}, [query]);

useLayoutEffect(() => {
setCurrentQuery(query);
}, [query]);
const isSearchAvailable =
searchStateType === "error" || currentQuery !== query;

const isSearchAvailable =
searchStateType === "error" || currentQuery !== query;
const handleSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
isSearchAvailable && onSubmit(currentQuery);
};

const handleSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
isSearchAvailable && onSubmit(currentQuery);
};
const bgColor = useColorModeValue(
"rgba(237, 242, 247, 0.9)",
"rgba(45, 55, 72, 0.9)"
);
const border = useColorModeValue(
"1px solid var(--chakra-colors-chakra-border-color)",
"1px solid var(--chakra-colors-chakra-body-bg)"
);

const bgColor = useColorModeValue(
"rgba(237, 242, 247, 0.9)",
"rgba(45, 55, 72, 0.9)"
);
const border = useColorModeValue(
"1px solid var(--chakra-colors-chakra-border-color)",
"1px solid var(--chakra-colors-chakra-body-bg)"
);

return (
<form onSubmit={handleSearch} {...rest}>
<Flex
padding={4}
direction="row"
gap={4}
alignItems="start"
background={bgColor}
borderBottom={border}
>
<Input
background="chakra-body-bg"
id="query"
value={currentQuery}
maxWidth="xl"
onChange={(event) => setCurrentQuery(event.target.value)}
/>
<Button
type="submit"
variant="solid"
colorScheme="blue"
disabled={!isSearchAvailable}
return (
<form onSubmit={handleSearch} {...rest}>
<Flex
padding={4}
direction="row"
gap={4}
alignItems="start"
background={bgColor}
borderBottom={border}
>
<FormattedMessage {...messages.searchButtonLabel} />
</Button>
</Flex>
</form>
);
};
<Input
background="chakra-body-bg"
id="query"
value={currentQuery}
maxWidth="xl"
onChange={(event) => setCurrentQuery(event.target.value)}
/>
<Button
type="submit"
variant="solid"
colorScheme="blue"
disabled={!isSearchAvailable}
>
<FormattedMessage {...messages.searchButtonLabel} />
</Button>
</Flex>
</form>
);
}
);
38 changes: 19 additions & 19 deletions src/features/repositories/pages/search/useRepositorySearchProps.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,34 @@
import { useEffect, useState } from "react";
import { PromiseState } from "../../../../common/state/usePromiseState";
import { useCallback, useState } from "react";
import { Repository } from "../../types";
import { SearchClient } from "../../../../common/api/clients/SearchClient";
import { useSearchClient } from "../../../../common/services/useSearchClient";
import { useQuery } from "../../../../common/services/useQuery";

export const useRepositorySearchProps = (
searchClient: SearchClient<Repository>
) => {
const [query, setQuery] = useState("react");
const [repositories, setRepositories] = useState<PromiseState<Repository[]>>({
type: "pending",
});

const searchRepositories = useSearchClient(
repositories,
setRepositories,
searchClient
const getData = useCallback(
(signal: AbortSignal) => searchClient.search(query, signal),
[query, searchClient]
);

useEffect(() => {
void searchRepositories.search(query);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const repositories = useQuery(getData);

const onQueryChange = useCallback(
(newQuery: string) => {
if (query === newQuery) {
void repositories.reload();
} else {
setQuery(newQuery);
}
},
[query, repositories]
);

return {
query,
repositories,
onQueryChange: (query: string) => {
setQuery(query);
void searchRepositories.search(query);
},
repositories: repositories.state,
onQueryChange,
};
};

0 comments on commit 1f91cf0

Please sign in to comment.