├── .github
└── workflows
│ ├── main.yml
│ └── size.yml
├── .gitignore
├── LICENSE
├── README.md
├── data
└── sample.json
├── example
├── .npmignore
├── DataContext.tsx
├── Result.tsx
├── index.html
├── index.tsx
├── package.json
├── tsconfig.json
└── yarn.lock
├── jest.config.js
├── package.json
├── src
├── index.tsx
├── types
│ └── index.ts
└── utils
│ ├── hasFilter.ts
│ └── worker.ts
├── test
└── useFilter.test.tsx
├── tsconfig.json
└── yarn.lock
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push]
3 | jobs:
4 | build:
5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
6 |
7 | runs-on: ${{ matrix.os }}
8 | strategy:
9 | matrix:
10 | node: ['12.x', '14.x']
11 | os: [ubuntu-latest, windows-latest, macOS-latest]
12 |
13 | steps:
14 | - name: Checkout repo
15 | uses: actions/checkout@v2
16 |
17 | - name: Use Node ${{ matrix.node }}
18 | uses: actions/setup-node@v1
19 | with:
20 | node-version: ${{ matrix.node }}
21 |
22 | - name: Install deps and build (with cache)
23 | uses: bahmutov/npm-install@v1
24 |
25 | - name: Lint
26 | run: yarn lint
27 |
28 | - name: Test
29 | run: yarn test
30 |
31 | - name: Build
32 | run: yarn build
33 |
--------------------------------------------------------------------------------
/.github/workflows/size.yml:
--------------------------------------------------------------------------------
1 | name: size
2 | on: [pull_request]
3 | jobs:
4 | size:
5 | runs-on: ubuntu-latest
6 | env:
7 | CI_JOB_NUMBER: 1
8 | steps:
9 | - uses: actions/checkout@v1
10 | - uses: andresz1/size-limit-action@v1
11 | with:
12 | github_token: ${{ secrets.GITHUB_TOKEN }}
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_Store
3 | node_modules
4 | .cache
5 | dist
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Lakhan Samani
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # `useFilter`
2 |
3 | A react hook to filter large amount of data in frontend using [Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers).In order to keep the main thread free and run the web application without any glitches we can leverage the use of [Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) which runs on a separate thread and can share the messages with main thread.
4 |
5 | ## [Live Demo](https://codesandbox.io/s/usefilter-demo1-mdfz8)
6 |
7 | ## How to use
8 |
9 | ### Installation
10 |
11 | - NPM: `npm install @promise_learning/usefilter`
12 | - Yarn: `yarn add @promise_learning/usefilter`
13 |
14 | ### Usage
15 |
16 | > We recommend using [react-window](https://www.npmjs.com/package/react-window) for rendering large data set. Also use [`useDebounce`](https://www.npmjs.com/package/use-debounce) hook with search
17 |
18 | ```jsx
19 | import { useFilter } from '@promise_learning/usefilter';
20 | import from './data.json';
21 |
22 |
23 | ///////////////////////////////////////////
24 | // handle this using the state in your app
25 | //////////////////////////////////////////
26 |
27 | const searchData = {
28 | query: '',
29 | fields: ['name'],
30 | };
31 |
32 | const filtersData = {
33 | category: ['Sci Fiction'],
34 | };
35 |
36 |
37 | export const App = () => {
38 | const { loading, data: result } = useFilter({ data, search: searchData, filters: filterData });
39 |
40 | if (loading) {
41 | return
Loading..
42 | }
43 |
44 | return (
45 | <>
46 | // render result
47 | >
48 | )
49 | }
50 | ```
51 |
52 | ### Parameters
53 |
54 | | Parameter | Type | Required |
55 | | --------- | ------------------------------------------------------------------------------------------------------------ | -------- |
56 | | data | Array | `true` |
57 | | search | Object -> `{query: '', fields: [], enableHighlighting: true}`. `query` is the search term, `fields` is the object keys to search on and `enableHighlighting` lets you highlight the matching part | `false` |
58 | | filters | Object -> Key Value Pair. Where `key` is a field from object in array and value could be possible value | `false` |
59 |
60 | ### Data Returned
61 |
62 | Object with following data is returned by the `useFilter` hook.
63 |
64 | | Key | Values | Description |
65 | | ------- | ---------------- | ------------------------------------------------ |
66 | | loading | `true` / `false` | Worker state if it is processing the data or not |
67 | | data | Array | filtered response based on the input |
68 |
69 | # When to use?
70 |
71 | - Filter / Search large list in frontend
72 | - Filter / Search large data table in frontend
73 |
74 | # License
75 |
76 | [MIT License](https://github.com/promise-learning/useFilter/blob/main/LICENSE)
77 |
--------------------------------------------------------------------------------
/example/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .cache
3 | dist
--------------------------------------------------------------------------------
/example/DataContext.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const DataContext = React.createContext([]);
4 |
5 | export const DataContextProvider = ({ children }) => {
6 | const [data, setData] = React.useState({ loading: true, data: [] });
7 | React.useEffect(() => {
8 | let isMounted = true;
9 |
10 | async function fetchData() {
11 | const res = await fetch(`sample.json`);
12 | const resData = await res.json();
13 |
14 | if (isMounted) {
15 | setData({
16 | loading: false,
17 | data: resData,
18 | });
19 | }
20 | }
21 |
22 | fetchData();
23 |
24 | return () => {
25 | isMounted = false;
26 | };
27 | }, []);
28 |
29 | if (data.loading) {
30 | return Loading data...
;
31 | }
32 | return (
33 | {children}
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/example/Result.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { FixedSizeList as List } from 'react-window';
3 | import { DataContext } from './DataContext';
4 |
5 | import { useFilter } from '../src';
6 |
7 | const Result = ({ search, filters }) => {
8 | const data = React.useContext(DataContext);
9 | const { data: result } = useFilter({ data, search, filters });
10 | return (
11 |
20 |
26 |
27 | id
28 |
29 |
30 | Director {' '}
31 |
32 |
33 | Type {' '}
34 |
35 |
36 |
37 |
38 | <>
39 | {result.length > 0 ? (
40 | <>
41 |
47 | {({ index, style }) => (
48 |
55 |
{result[index].show_id as string}
56 |
61 |
{result[index].type as string}
62 |
63 | )}
64 |
65 | >
66 | ) : (
67 |
Data Not Found
68 | )}
69 | >
70 |
71 | );
72 | };
73 |
74 | export default Result;
75 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Playground
8 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/example/index.tsx:
--------------------------------------------------------------------------------
1 | import 'react-app-polyfill/ie11';
2 | import * as React from 'react';
3 | import * as ReactDOM from 'react-dom';
4 | import { useDebounce } from 'use-debounce';
5 | import { DataContextProvider } from './DataContext';
6 | import Result from './Result';
7 |
8 | const searchData = {
9 | query: '',
10 | fields: ['director'],
11 | enableHighlighting: true,
12 | };
13 |
14 | const filtersData: {
15 | type: string[];
16 | } = {
17 | type: [],
18 | };
19 |
20 | const App = () => {
21 | const [filters, setFilters] = React.useState(filtersData);
22 | const [search, setSearch] = React.useState('');
23 | const [value] = useDebounce(search, 300);
24 |
25 | const handleSearchChange = e => {
26 | setSearch(e.target.value);
27 | };
28 |
29 | const handleFilterChange = (e, label) => {
30 | setFilters({
31 | ...filters,
32 | type: e.target.checked
33 | ? [...filters.type, label]
34 | : filters.type.filter(i => i !== label),
35 | });
36 | };
37 |
38 | return (
39 |
40 |
76 |
77 | );
78 | };
79 |
80 | ReactDOM.render(, document.getElementById('root'));
81 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "scripts": {
7 | "start": "mkdir -p ./dist && cp ../data/sample.json ./dist/ && parcel index.html",
8 | "build": "mkdir -p ./dist && cp ../data/sample.json ./dist/ && parcel build index.html"
9 | },
10 | "dependencies": {
11 | "react-app-polyfill": "^1.0.0",
12 | "react-window": "^1.8.6",
13 | "use-debounce": "^6.0.1"
14 | },
15 | "alias": {
16 | "react": "../node_modules/react",
17 | "react-dom": "../node_modules/react-dom/profiling",
18 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling",
19 | "@koale/useworker": "../node_modules/@koale/useworker"
20 | },
21 | "devDependencies": {
22 | "@types/react": "^16.9.11",
23 | "@types/react-dom": "^16.8.4",
24 | "parcel": "1.12.3",
25 | "typescript": "^3.4.5"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": false,
4 | "target": "es5",
5 | "module": "commonjs",
6 | "jsx": "react",
7 | "moduleResolution": "node",
8 | "noImplicitAny": false,
9 | "noUnusedLocals": false,
10 | "noUnusedParameters": false,
11 | "removeComments": true,
12 | "strictNullChecks": true,
13 | "preserveConstEnums": true,
14 | "sourceMap": true,
15 | "lib": ["es2015", "es2016", "dom"],
16 | "types": ["node"],
17 | "resolveJsonModule": true
18 | }
19 | }
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | setupFiles: ['jsdom-worker'],
3 | };
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.3.1",
3 | "license": "MIT",
4 | "main": "dist/index.js",
5 | "typings": "dist/index.d.ts",
6 | "files": [
7 | "dist",
8 | "src"
9 | ],
10 | "engines": {
11 | "node": ">=12"
12 | },
13 | "scripts": {
14 | "start": "tsdx watch",
15 | "build": "tsdx build",
16 | "test": "tsdx test --passWithNoTests",
17 | "lint": "tsdx lint",
18 | "prepare": "tsdx build",
19 | "size": "size-limit",
20 | "analyze": "size-limit --why"
21 | },
22 | "peerDependencies": {
23 | "react": ">=16"
24 | },
25 | "husky": {
26 | "hooks": {
27 | "pre-commit": "tsdx lint"
28 | }
29 | },
30 | "prettier": {
31 | "printWidth": 80,
32 | "semi": true,
33 | "singleQuote": true,
34 | "trailingComma": "es5"
35 | },
36 | "name": "@promise_learning/usefilter",
37 | "authors": [
38 | "Lakhan Samani ",
39 | "Yash Joshi "
40 | ],
41 | "module": "dist/usefilter.esm.js",
42 | "repository": "https://github.com/promise-learning/useFilter",
43 | "size-limit": [
44 | {
45 | "path": "dist/usefilter.cjs.production.min.js",
46 | "limit": "10 KB"
47 | },
48 | {
49 | "path": "dist/usefilter.esm.js",
50 | "limit": "10 KB"
51 | }
52 | ],
53 | "devDependencies": {
54 | "@size-limit/preset-small-lib": "^4.10.2",
55 | "@testing-library/react-hooks": "^6.0.0",
56 | "@types/react": "^17.0.3",
57 | "@types/react-dom": "^17.0.3",
58 | "husky": "^6.0.0",
59 | "jsdom-worker": "^0.2.1",
60 | "node-fetch": "^2.6.1",
61 | "react": "^17.0.2",
62 | "react-dom": "^17.0.2",
63 | "size-limit": "^4.10.2",
64 | "tsdx": "^0.14.1",
65 | "tslib": "^2.2.0",
66 | "typescript": "^4.2.4"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import { useEffect, useState, useMemo } from 'react';
3 |
4 | import { HookParams } from './types';
5 | import { hasFilters } from './utils/hasFilter';
6 | import { filterFn, highlightSearch } from './utils/worker';
7 |
8 | const code = `
9 | ${highlightSearch.toString()}
10 | ${filterFn.toString()}
11 | onmessage = function(e) {
12 | const params = e.data;
13 | const filterData = filterFn(params)
14 | postMessage(filterData);
15 | };
16 | `;
17 |
18 | const worker = new Worker(
19 | URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
20 | );
21 |
22 | export function useFilter- >({
23 | data,
24 | search,
25 | filters,
26 | }: HookParams
- ) {
27 | const [loading, setLoading] = useState(false);
28 | const [result, setResult] = useState[]>([]);
29 |
30 | const isHavingFilters = useMemo(() => hasFilters(search, filters), [
31 | search,
32 | filters,
33 | ]);
34 |
35 | useEffect(() => {
36 | let isMounted = true;
37 |
38 | if (isMounted) {
39 | setLoading(true);
40 | }
41 | if (isHavingFilters) {
42 | worker.postMessage({ data, search, filters });
43 | worker.onmessage = e => {
44 | if (isMounted) {
45 | setLoading(false);
46 | setResult(e.data);
47 | }
48 | };
49 |
50 | worker.onerror = e => {
51 | console.error(`Web worker error`, e);
52 | if (isMounted) {
53 | setLoading(false);
54 | }
55 | };
56 | }
57 |
58 | return () => {
59 | isMounted = false;
60 | };
61 | }, [search, filters]);
62 |
63 | return { loading, data: isHavingFilters ? result : data };
64 | }
65 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface Filter {
2 | [name: string]: string | string[];
3 | }
4 |
5 | export interface Search {
6 | query: string;
7 | fields: string[];
8 | enableHighlighting?: boolean;
9 | }
10 |
11 | export interface HookParams
- {
12 | data: Item[];
13 | filters?: Filter;
14 | search?: Search;
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/hasFilter.ts:
--------------------------------------------------------------------------------
1 | import { Filter, Search } from '../types';
2 |
3 | export function hasFilters(
4 | search: Search = {
5 | query: '',
6 | fields: [],
7 | },
8 | filters: Filter = {}
9 | ): boolean {
10 | if (search && search.query && search.query.trim()) return true;
11 |
12 | return Object.keys(filters).some(
13 | item => filters[item] && filters[item].length
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/worker.ts:
--------------------------------------------------------------------------------
1 | import { HookParams } from '../types';
2 |
3 | export function highlightSearch(value: string, searchQuery: string): string {
4 | const reg = new RegExp(
5 | searchQuery.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'),
6 | 'i'
7 | );
8 |
9 | return value.replace(
10 | reg,
11 | str => `${str}`
12 | );
13 | }
14 |
15 | export function filterFn
- >({
16 | data = [],
17 | filters = {},
18 | search = {
19 | query: '',
20 | fields: [],
21 | },
22 | }: HookParams
- ) {
23 | let result = Object.assign(data);
24 |
25 | if (search && search.query && search.query.trim()) {
26 | const queryLowerCase = search.query.toLowerCase();
27 | if (search.enableHighlighting) {
28 | result = data.reduce((acc: Item[], item: Item) => {
29 | let hasSearchTerm = false;
30 | const itemToReturn = Object.assign({}, item);
31 | search.fields.forEach((field: string) => {
32 | const fieldVal = (item[field] as string).toString().toLowerCase();
33 | if (fieldVal.includes(queryLowerCase)) {
34 | hasSearchTerm = true;
35 | // @ts-ignore
36 | itemToReturn[field] = highlightSearch(fieldVal, queryLowerCase);
37 | }
38 | });
39 |
40 | if (hasSearchTerm) {
41 | return acc.concat(itemToReturn);
42 | }
43 |
44 | return acc;
45 | }, []);
46 | } else {
47 | result = data.filter((item: Item) =>
48 | search.fields.some((field: string) => {
49 | const fieldLowerCase = (item[field] as string).toLowerCase();
50 | return fieldLowerCase.includes(queryLowerCase);
51 | })
52 | );
53 | }
54 | }
55 |
56 | if (filters && Object.keys(filters).length) {
57 | Object.keys(filters).forEach((field: string) => {
58 | if (filters[field] && filters[field].length) {
59 | result = result.filter((item: Item) =>
60 | filters[field].includes(item[field] as string)
61 | );
62 | }
63 | });
64 | }
65 | return result;
66 | }
67 |
--------------------------------------------------------------------------------
/test/useFilter.test.tsx:
--------------------------------------------------------------------------------
1 | import 'jsdom-worker';
2 | import { renderHook, cleanup } from '@testing-library/react-hooks';
3 |
4 | // @ts-ignore
5 | import data from '../data/sample.json';
6 | import { useFilter } from '../src';
7 |
8 | afterEach(() => {
9 | cleanup();
10 | });
11 |
12 | test('should filter data using useFilter', async () => {
13 | const searchData = {
14 | query: 'jorge',
15 | fields: ['director'],
16 | enableHighlighting: true,
17 | };
18 | const filters = {
19 | type: 'Movie',
20 | };
21 | const { result, waitForNextUpdate } = renderHook(() =>
22 | // @ts-ignore
23 | useFilter({ data, search: searchData, filters })
24 | );
25 | expect(result.current.loading).toBe(true);
26 |
27 | await waitForNextUpdate();
28 |
29 | expect(result.current.loading).toBe(false);
30 | expect(result.current.data.length).toBe(7);
31 | expect(result.current.data[0].director).toContain(
32 | ``
33 | );
34 | });
35 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
3 | "include": ["src", "types", "dist/utils/worker.js"],
4 | "compilerOptions": {
5 | "module": "esnext",
6 | "lib": ["dom", "esnext"],
7 | "importHelpers": true,
8 | // output .d.ts declaration files for consumers
9 | "declaration": true,
10 | // output .js.map sourcemap files for consumers
11 | "sourceMap": true,
12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index
13 | "rootDir": "./src",
14 | // stricter type-checking for stronger correctness. Recommended by TS
15 | "strict": true,
16 | // linter checks for common issues
17 | "noImplicitReturns": true,
18 | "noFallthroughCasesInSwitch": true,
19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | // use Node's module resolution algorithm, instead of the legacy TS one
23 | "moduleResolution": "node",
24 | // transpile JSX to React.createElement
25 | "jsx": "react",
26 | // interop between ESM and CJS modules. Recommended by TS
27 | "esModuleInterop": true,
28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
29 | "skipLibCheck": true,
30 | // error out if import and file system have a casing mismatch. Recommended by TS
31 | "forceConsistentCasingInFileNames": true,
32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc`
33 | "noEmit": true
34 | }
35 | }
36 |
--------------------------------------------------------------------------------