├── .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 |
41 |

useFilter Demo

42 | 48 |
49 | Filter 50 | 58 | 66 |
67 | 75 |
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 | --------------------------------------------------------------------------------