Lodash - Battle-tested utility functions with tree-shakable imports for React apps
Recipe
# Tree-shakable ES module version (recommended)
npm install lodash-es
npm install -D @types/lodash-es
# Or classic version with cherry-picked imports
npm install lodash
npm install -D @types/lodash// Import individual functions for tree-shaking
import debounce from "lodash-es/debounce";
import groupBy from "lodash-es/groupBy";
import cloneDeep from "lodash-es/cloneDeep";
// Or named imports (works with lodash-es)
import { debounce, groupBy, cloneDeep } from "lodash-es";When to reach for this: You need reliable utility functions for debouncing, throttling, deep cloning, grouping, or merging that handle edge cases better than quick hand-rolled solutions.
Working Example
// app/components/SearchWithDebounce.tsx
"use client";
import { useState, useCallback, useMemo, useEffect } from "react";
import debounce from "lodash-es/debounce";
import groupBy from "lodash-es/groupBy";
interface Product {
id: number;
name: string;
category: string;
price: number;
}
const ALL_PRODUCTS: Product[] = [
{ id: 1, name: "React Handbook", category: "books", price: 29 },
{ id: 2, name: "TypeScript Guide", category: "books", price: 35 },
{ id: 3, name: "Mechanical Keyboard", category: "electronics", price: 150 },
{ id: 4, name: "USB-C Hub", category: "electronics", price: 45 },
{ id: 5, name: "Standing Desk", category: "furniture", price: 400 },
{ id: 6, name: "Monitor Arm", category: "furniture", price: 80 },
];
export default function SearchWithDebounce() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Product[]>(ALL_PRODUCTS);
const searchProducts = useMemo(
() =>
debounce((searchQuery: string) => {
const filtered = ALL_PRODUCTS.filter((p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
);
setResults(filtered);
}, 300),
[]
);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
searchProducts.cancel();
};
}, [searchProducts]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
searchProducts(e.target.value);
},
[searchProducts]
);
const grouped = groupBy(results, "category");
return (
<div className="max-w-lg mx-auto p-6">
<input
value={query}
onChange={handleChange}
placeholder="Search products..."
className="w-full border rounded px-3 py-2 mb-4"
/>
{Object.entries(grouped).map(([category, items]) => (
<div key={category} className="mb-4">
<h3 className="font-bold capitalize text-lg">{category}</h3>
<ul className="mt-1 space-y-1">
{items.map((item) => (
<li key={item.id} className="flex justify-between">
<span>{item.name}</span>
<span className="text-gray-500">${item.price}</span>
</li>
))}
</ul>
</div>
))}
<p className="text-sm text-gray-400 mt-4">
{results.length} results found
</p>
</div>
);
}What this demonstrates:
- Debounced search input preventing excessive filtering on every keystroke
groupByto organize results by category- Proper cleanup of debounced function on component unmount
useMemoto create a stable debounced function reference
Deep Dive
How It Works
lodash-esprovides ES module exports, enabling bundlers (webpack, Rollup, esbuild) to tree-shake unused functionsdebouncedelays function execution until a specified wait time has elapsed since the last call;cancel()prevents pending invocationsthrottlelimits execution to at most once per interval, useful for scroll/resize handlersgroupBycreates an object where keys come from the iteratee and values are arrays of matching elementscloneDeeprecursively copies objects, including nested objects, arrays, Maps, Sets, and Date instancesmergedeep-merges objects, whileObject.assignand spread only do shallow merging
Variations
Throttle for scroll/resize handlers:
"use client";
import { useEffect, useState } from "react";
import throttle from "lodash-es/throttle";
export function useScrollPosition() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = throttle(() => {
setScrollY(window.scrollY);
}, 100);
window.addEventListener("scroll", handleScroll);
return () => {
handleScroll.cancel();
window.removeEventListener("scroll", handleScroll);
};
}, []);
return scrollY;
}Deep merge configuration objects:
import merge from "lodash-es/merge";
const defaultConfig = {
theme: { colors: { primary: "#3b82f6", secondary: "#64748b" } },
features: { darkMode: false, notifications: true },
};
const userConfig = {
theme: { colors: { primary: "#ef4444" } },
features: { darkMode: true },
};
const config = merge({}, defaultConfig, userConfig);
// { theme: { colors: { primary: "#ef4444", secondary: "#64748b" } },
// features: { darkMode: true, notifications: true } }Safe deep cloning:
import cloneDeep from "lodash-es/cloneDeep";
const original = {
nested: { value: 42, date: new Date(), set: new Set([1, 2, 3]) },
};
const copy = cloneDeep(original);
copy.nested.value = 100;
console.log(original.nested.value); // 42 (unchanged)Other common utilities:
import pick from "lodash-es/pick";
import omit from "lodash-es/omit";
import uniqBy from "lodash-es/uniqBy";
import chunk from "lodash-es/chunk";
import get from "lodash-es/get";
// Pick specific keys from an object
const user = { id: 1, name: "Alice", email: "alice@example.com", password: "secret" };
const safe = pick(user, ["id", "name", "email"]);
// Remove keys
const noPassword = omit(user, ["password"]);
// Deduplicate by a property
const unique = uniqBy(users, "email");
// Split array into pages
const pages = chunk(items, 10); // [[...10], [...10], ...]
// Safe deep property access (consider optional chaining instead)
const value = get(config, "deeply.nested.value", "default");TypeScript Notes
@types/lodash-esprovides full type definitions for all functionsdebounceandthrottlereturnDebouncedFunc<T>withcancel()andflush()methodsgroupByreturnsDictionary<T[]>where keys are strings- Most functions preserve input types; generics are inferred automatically
import type { DebouncedFunc } from "lodash-es";
// Explicitly typed debounced function
const debouncedSearch: DebouncedFunc<(query: string) => void> = debounce(
(query: string) => {
console.log("Searching:", query);
},
300
);Gotchas
-
Importing all of lodash —
import _ from "lodash"bundles the entire library (around 70KB minified). Fix: Uselodash-eswith named imports or import specific paths likelodash-es/debounce. -
Debounce in render — Creating a debounced function inside render creates a new instance every render, defeating the purpose. Fix: Wrap with
useMemooruseCallbackand provide a stable reference. -
Memory leaks from debounce/throttle — Pending debounced calls can fire after component unmount. Fix: Call
.cancel()in the cleanup function ofuseEffect. -
cloneDeep is expensive — Deep cloning large objects is slow. Fix: Use
structuredClone()(native, available in all modern browsers and Node 17+) for simple cases. UsecloneDeeponly when you need to handle functions or special Lodash features. -
get vs optional chaining —
_.get(obj, "a.b.c")is redundant now that JavaScript hasobj?.a?.b?.c. Fix: Prefer optional chaining for property access. Usegetonly when the path is dynamic (a variable). -
merge mutates the target —
merge(target, source)mutatestarget. Fix: Pass an empty object as the first argument:merge({}, defaults, overrides).
Alternatives
| Function | Native Alternative | When to Use Lodash |
|---|---|---|
cloneDeep | structuredClone() | When cloning functions or class instances |
get | Optional chaining (?.) | When path is a dynamic string variable |
debounce | No native equivalent | Always (or use a small use-debounce package) |
throttle | No native equivalent | Always |
groupBy | Object.groupBy() (ES2024) | When targeting older environments |
merge | Spread {...a, ...b} | When you need deep merge (spread is shallow) |
uniqBy | [...new Map(arr.map(x => [x.key, x])).values()] | When readability matters |
chunk | No native equivalent | Always |
FAQs
Why should I use lodash-es instead of lodash?
lodash-esprovides ES module exports that bundlers can tree-shakeimport { debounce } from "lodash"bundles the entire library (~70KB)import { debounce } from "lodash-es"includes onlydebounceand its dependencies- Always install
@types/lodash-esalongside for TypeScript support
How do I correctly use debounce in a React component?
const searchFn = useMemo(
() => debounce((query: string) => {
// perform search
}, 300),
[]
);
useEffect(() => {
return () => searchFn.cancel();
}, [searchFn]);Wrap in useMemo for a stable reference. Clean up with .cancel() on unmount.
Gotcha: Why does my debounced function fire on every keystroke?
- Creating the debounced function inside the render body creates a new instance each render
- Each new instance has its own timer, so the delay never accumulates
- Fix: wrap with
useMemooruseCallbackso the same debounced function persists across renders
What is the difference between debounce and throttle?
debouncewaits until N ms of inactivity before executing (good for search inputs)throttleexecutes at most once per N ms interval (good for scroll/resize handlers)- Both return functions with
.cancel()and.flush()methods
Gotcha: Why does merge(target, source) modify my original object?
mergemutates the first argument (the target)- Always pass an empty object as the first argument:
merge({}, defaults, overrides) - This creates a new object and leaves
defaultsandoverridesunchanged
When should I use cloneDeep vs structuredClone?
structuredClone()is native and handles most types (objects, arrays, Maps, Sets, Dates)cloneDeepalso handles functions, RegExp with flags, and lodash-specific wrappers- Prefer
structuredClone()for simple cases -- no dependency needed - Use
cloneDeepwhen cloning objects that contain functions or class instances
Is _.get(obj, "a.b.c") still useful now that we have optional chaining?
- For static paths, prefer
obj?.a?.b?.c-- it is native and type-safe getis still useful when the path is a dynamic variable:get(obj, dynamicPath, defaultValue)getalso supports array notation in paths:get(obj, "items[0].name")
How do I type a debounced function in TypeScript?
import type { DebouncedFunc } from "lodash-es";
const debouncedSearch: DebouncedFunc<(q: string) => void> =
debounce((q: string) => {
console.log("Searching:", q);
}, 300);DebouncedFunc<T> adds .cancel() and .flush() to the wrapped function type.
How does groupBy work and what type does it return in TypeScript?
import groupBy from "lodash-es/groupBy";
const products = [
{ name: "Book", category: "media" },
{ name: "CD", category: "media" },
{ name: "Desk", category: "furniture" },
];
const grouped = groupBy(products, "category");
// { media: [...], furniture: [...] }Returns Dictionary<T[]> where keys are strings.
How do I safely remove keys from an object without mutation?
import omit from "lodash-es/omit";
const user = { id: 1, name: "Alice", password: "secret" };
const safe = omit(user, ["password"]);
// { id: 1, name: "Alice" }omit returns a new object; the original is not modified.
How do I split an array into fixed-size chunks?
import chunk from "lodash-es/chunk";
const items = [1, 2, 3, 4, 5, 6, 7];
const pages = chunk(items, 3);
// [[1, 2, 3], [4, 5, 6], [7]]Useful for pagination or batching API requests.
How do I prevent memory leaks from debounced/throttled functions in React?
- Pending debounced calls fire after the component unmounts if not cancelled
- Call
.cancel()in theuseEffectcleanup function - This prevents state updates on unmounted components
- Applies to both
debounceandthrottle
Related
- React Hooks — Using debounce/throttle in custom hooks
- React Patterns — Performance patterns with utility functions
- TanStack Query — Built-in deduplication reduces need for debounce in data fetching