Lodash Utilities Worth Knowing: Arrays, Objects, debounce/throttle and TypeScript Use
Key takeaways
Which Lodash functions still earn their place next to modern JavaScript, and the traps in the popular ones: merge mutating its target, debounce returning stale results, memoize caching forever, and imports that pull in the whole library.
Introduction
Lodash is a modern JavaScript utility library delivering modularity, performance, and extras. It makes working with arrays, objects, strings, functions, and numbers easier and more consistent.
Why Lodash?
Native JavaScript:
// Deep clone (doesn't work properly)
const clone = JSON.parse(JSON.stringify(obj)); // Loses functions, dates
// Debounce (complex to implement)
let timeout;
function debounce(fn, delay) {
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
With Lodash:
// Deep clone
const clone = _.cloneDeep(obj);
// Debounce
const debouncedFn = _.debounce(fn, delay);
The hand-written debounce above also shows why people reach for the library: it shares one timeout variable across every function it wraps, so debouncing two different handlers makes them cancel each other, and it has no way to cancel, flush, or run on the leading edge. Getting those details right is exactly what a utility library is for.
Lodash next to modern JavaScript
Lodash dates from 2012, when browsers lacked most of the array and object helpers we now take for granted, and it is still one of the most depended-on packages on npm, often indirectly through other libraries. A lot of it is now redundant in new code:
| Lodash | Native equivalent |
|---|---|
_.map, _.filter, _.find, _.reduce | Array.prototype methods |
_.flatten, _.flattenDeep | arr.flat(), arr.flat(Infinity) |
_.get(obj, 'a.b.c', d) | obj?.a?.b?.c ?? d |
_.toPairs, _.fromPairs | Object.entries, Object.fromEntries |
_.uniq | [...new Set(arr)] |
_.groupBy | Object.groupBy (ES2024) |
_.cloneDeep (plain data) | structuredClone |
What remains genuinely useful is the set of functions that are fiddly to write correctly: debounce and throttle with cancel/flush and leading/trailing options, deep merge, isEqual for deep equality, the *By variants (uniqBy, differenceBy, keyBy), and string case conversion. The practical approach in a new project is to use native methods by default and import the few Lodash functions that save real code, individually. structuredClone is not a drop-in replacement for cloneDeep in every case: it throws on functions and DOM nodes and does not preserve class prototypes, while cloneDeep copies functions by reference.
Alternatives:
- Ramda: Functional programming focus (auto-curried, data-last), different API
- Underscore: Lodash predecessor, fewer features
- es-toolkit / remeda: newer, TypeScript-first utility libraries with smaller bundles
- Native ES2015+: Covers most simple operations
Installation
# npm
npm install lodash
# yarn
yarn add lodash
# CDN (browser)
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js"></script>
Import Methods
// Import entire library
import _ from 'lodash';
// Import individual functions (recommended for tree-shaking)
import debounce from 'lodash/debounce';
import cloneDeep from 'lodash/cloneDeep';
// CommonJS
const _ = require('lodash');
const debounce = require('lodash/debounce');
The lodash package is CommonJS, so bundlers cannot tree-shake it: import { debounce } from 'lodash' looks selective but still includes the whole library. The per-method path (lodash/debounce) loads only that function and its internal dependencies. For ESM projects, lodash-es exposes the same functions as ES modules that bundlers can tree-shake with named imports. Mixing styles in one project (some files importing lodash, others lodash-es) bundles both copies, which is a common reason a bundle analyzer shows Lodash twice.
Array Methods
Map, Filter, Reduce
const users = [
{ id: 1, name: 'Alice', age: 30, active: true },
{ id: 2, name: 'Bob', age: 25, active: false },
{ id: 3, name: 'Charlie', age: 35, active: true },
];
// Map
_.map(users, 'name'); // ['Alice', 'Bob', 'Charlie']
_.map(users, user => user.age * 2); // [60, 50, 70]
// Filter
_.filter(users, { active: true }); // Active users
_.filter(users, user => user.age > 30); // Age > 30
// Find
_.find(users, { name: 'Alice' }); // First match
_.findLast(users, { active: true }); // Last match
// Reduce
_.reduce(users, (sum, user) => sum + user.age, 0); // Total age: 90
The reason these exist next to the native methods is the iteratee shorthand: 'name' means “take the name property”, and { active: true } means “objects that match these properties” (a partial deep comparison). They also work on objects and on null/undefined without throwing, returning an empty result instead. That tolerance is convenient but can hide bugs: _.map(undefined, 'name') returns [] where undefined.map would have told you the data was missing.
Unique, Flatten, Chunk
// Unique
_.uniq([1, 2, 2, 3, 3, 4]); // [1, 2, 3, 4]
_.uniqBy([{ x: 1 }, { x: 2 }, { x: 1 }], 'x'); // [{ x: 1 }, { x: 2 }]
// Flatten
_.flatten([1, [2, [3, [4]]]]); // [1, 2, [3, [4]]]
_.flattenDeep([1, [2, [3, [4]]]]); // [1, 2, 3, 4]
// Chunk (split into groups)
_.chunk([1, 2, 3, 4, 5, 6], 2); // [[1, 2], [3, 4], [5, 6]]
_.chunk(['a', 'b', 'c', 'd'], 3); // [['a', 'b', 'c'], ['d']]
// Compact (remove falsy values)
_.compact([0, 1, false, 2, '', 3, null, undefined]); // [1, 2, 3]
Difference, Intersection, Union
const arr1 = [1, 2, 3];
const arr2 = [2, 3, 4];
// Difference (in arr1 but not arr2)
_.difference(arr1, arr2); // [1]
// Intersection (in both)
_.intersection(arr1, arr2); // [2, 3]
// Union (all unique values)
_.union(arr1, arr2); // [1, 2, 3, 4]
// With objects
const users1 = [{ id: 1 }, { id: 2 }];
const users2 = [{ id: 2 }, { id: 3 }];
_.differenceBy(users1, users2, 'id'); // [{ id: 1 }]
_.intersectionBy(users1, users2, 'id'); // [{ id: 2 }]
The plain versions compare with SameValueZero, the same rule as Set and includes, so they work for numbers and strings but never match two distinct objects, even with identical contents: _.difference([{ id: 1 }], [{ id: 1 }]) returns the object unchanged. That is what the By variants are for (compare by a derived key), and differenceWith/intersectionWith take a comparator such as _.isEqual for full deep comparison, at a higher cost.
Sorting
const users = [
{ name: 'Charlie', age: 35 },
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
];
// Sort by single property
_.sortBy(users, 'age');
// [{ name: 'Bob', age: 25 }, { name: 'Alice', age: 30 }, { name: 'Charlie', age: 35 }]
// Sort by multiple properties
_.sortBy(users, ['age', 'name']);
// Sort with custom order
_.orderBy(users, ['age'], ['desc']);
// [{ name: 'Charlie', age: 35 }, ...]
Unlike Array.prototype.sort, sortBy and orderBy return a new array and leave the input untouched, and both are stable, so equal elements keep their original order. sortBy only sorts ascending; orderBy takes a direction per key. Values are compared with <, which sorts strings by UTF-16 code unit (‘Zoe’ before ‘adam’), so for user-facing text, a comparator with localeCompare or Intl.Collator gives the order people expect.
Take, Drop, Sample
const arr = [1, 2, 3, 4, 5];
// Take first n
_.take(arr, 2); // [1, 2]
_.takeRight(arr, 2); // [4, 5]
// Drop first n
_.drop(arr, 2); // [3, 4, 5]
_.dropRight(arr, 2); // [1, 2, 3]
// Random sample
_.sample(arr); // Random element
_.sampleSize(arr, 2); // 2 random elements
// Shuffle
_.shuffle([1, 2, 3, 4, 5]); // Random order
Object Methods
Get, Set, Has
const user = {
name: 'Alice',
address: {
city: 'New York',
zip: '10001',
},
};
// Get (safe nested access)
_.get(user, 'address.city'); // 'New York'
_.get(user, 'address.country', 'USA'); // 'USA' (default)
_.get(user, 'profile.bio'); // undefined
// Set (create nested path)
_.set(user, 'profile.bio', 'Developer');
// { name: 'Alice', address: {...}, profile: { bio: 'Developer' } }
// Has
_.has(user, 'address.city'); // true
_.has(user, 'address.country'); // false
_.get was essential before optional chaining; today user?.address?.city ?? 'USA' does the same with type checking in TypeScript. get is still useful when the path is data (a string from configuration or a form field name), not code. Its default only applies when the result is undefined, not null. _.set mutates the object and creates intermediate objects, or arrays when a path segment is numeric (_.set({}, 'items.0.name', 'x') creates an array). Because set, merge and similar functions write through user-controlled paths, older Lodash versions had prototype pollution vulnerabilities where a key like __proto__ modified Object.prototype; stay on the latest 4.17.x release and avoid passing untrusted keys to them anyway.
Pick, Omit
const user = {
id: 1,
name: 'Alice',
email: '[email protected]',
password: 'secret',
role: 'admin',
};
// Pick (select properties)
_.pick(user, ['id', 'name', 'email']);
// { id: 1, name: 'Alice', email: '[email protected]' }
// Omit (exclude properties)
_.omit(user, ['password', 'role']);
// { id: 1, name: 'Alice', email: '[email protected]' }
Merge, Assign
const obj1 = { a: 1, b: { x: 1 } };
const obj2 = { b: { y: 2 }, c: 3 };
// Merge (deep) — mutates and returns obj1!
_.merge(obj1, obj2);
// { a: 1, b: { x: 1, y: 2 }, c: 3 }
// Assign (shallow) — with the original obj1 (before the merge above)
_.assign({}, obj1, obj2);
// { a: 1, b: { y: 2 }, c: 3 }
The biggest merge trap is that it mutates its first argument. After the call above, obj1 itself has changed, and if it was a shared default configuration, every later user sees the merged values. Write _.merge({}, defaults, overrides) to keep the inputs intact. A second surprise is how arrays merge: element by element by index, so merging { tags: ['a', 'b'] } with { tags: ['c'] } gives ['c', 'b'], not ['c'] and not ['a', 'b', 'c']. _.mergeWith with a customizer that returns the source array replaces arrays instead. Also, merge skips source values that are undefined, which means you cannot use it to clear a field. assign is the same as Object.assign and the spread operator: later objects overwrite whole top-level properties.
Keys, Values, Entries
const obj = { a: 1, b: 2, c: 3 };
// Keys
_.keys(obj); // ['a', 'b', 'c']
// Values
_.values(obj); // [1, 2, 3]
// Entries (key-value pairs)
_.toPairs(obj); // [['a', 1], ['b', 2], ['c', 3]]
// From entries
_.fromPairs([['a', 1], ['b', 2]]); // { a: 1, b: 2 }
Clone
const obj = {
name: 'Alice',
address: { city: 'NYC' },
hobbies: ['coding', 'reading'],
};
// Shallow clone
const shallow = _.clone(obj);
shallow.address.city = 'LA'; // Affects original!
// Deep clone
const deep = _.cloneDeep(obj);
deep.address.city = 'LA'; // Doesn't affect original
cloneDeep handles cases where JSON.parse(JSON.stringify(obj)) fails: Date objects stay Dates (JSON turns them into strings), Map and Set are copied, undefined values are kept, and circular references do not throw. Functions are not cloned but copied by reference. It is also expensive on large objects, since it walks everything. In state-management code, deep-cloning a whole store on every update is a frequent performance problem; copying only the path you change ({ ...state, user: { ...state.user, name } }) or using a library like Immer is usually the better tool.
Collection Methods
const users = [
{ name: 'Alice', age: 30, active: true },
{ name: 'Bob', age: 25, active: false },
{ name: 'Charlie', age: 35, active: true },
];
// Group by
_.groupBy(users, 'active');
// {
// true: [{ name: 'Alice', ... }, { name: 'Charlie', ... }],
// false: [{ name: 'Bob', ... }]
// }
// Count by
_.countBy(users, 'active'); // { true: 2, false: 1 }
// Key by (index by property)
_.keyBy(users, 'name');
// {
// Alice: { name: 'Alice', ... },
// Bob: { name: 'Bob', ... },
// Charlie: { name: 'Charlie', ... }
// }
// Partition (split by condition)
_.partition(users, { active: true });
// [[active users], [inactive users]]
Object keys are always strings, which shows in the groupBy output: the groups are 'true' and 'false', not booleans, and grouping by a numeric id gives string keys too. keyBy keeps only the last element for a duplicate key, silently dropping the others, so use groupBy when keys may repeat. Object.groupBy (ES2024) is the native equivalent of groupBy and returns an object with a null prototype, which means result.hasOwnProperty does not exist on it; Map.groupBy keeps non-string keys.
Function Utilities
Debounce
// Debounce (wait until user stops typing)
const search = _.debounce((query) => {
console.log('Searching for:', query);
}, 300);
// User types: a, ab, abc
// Only searches once after 300ms of no typing
// Cancel pending debounce
search.cancel();
// Execute immediately
search.flush();
Debounce restarts a timer on every call and runs the function once the calls stop for wait milliseconds, with the arguments of the last call. By default it fires on the trailing edge only; { leading: true } runs it on the first call as well, and { maxWait: 1000 } guarantees it runs at least once a second even if calls never stop, which is useful for autosave while someone types continuously. cancel() drops a pending call (use it when a component unmounts or a dialog closes), and flush() runs it immediately (use it before navigating away so the last edit is saved).
Throttle
// Throttle (execute at most once per interval)
const handleScroll = _.throttle(() => {
console.log('Scrolling');
}, 100);
window.addEventListener('scroll', handleScroll);
// Execute at most once per 100ms
// Cancel
handleScroll.cancel();
Throttle is debounce with maxWait equal to wait: it runs at most once per interval while events keep coming, on both the leading and trailing edges by default. The rule of thumb is debounce for “do it when the user is done” (search boxes, resize end, validation) and throttle for “do it regularly while it happens” (scroll position, drag, progress reporting). For scroll and animation work specifically, requestAnimationFrame often fits better than a fixed interval, and passive event listeners ({ passive: true }) avoid blocking scrolling.
Once
// Execute only once
const initialize = _.once(() => {
console.log('Initialized');
});
initialize(); // Logs: "Initialized"
initialize(); // Does nothing
initialize(); // Does nothing
Memoize
// Cache function results
const fibonacci = _.memoize((n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
fibonacci(40); // First call: slow
fibonacci(40); // Cached: instant
// Custom cache key
const getUser = _.memoize(
async (id) => {
const response = await fetch(`/users/${id}`);
return response.json();
},
(id) => `user-${id}` // Cache key
);
Three properties of memoize matter in real code. By default, the cache key is the first argument only, so a function of two arguments called as f(1, 2) and f(1, 3) returns the first result both times unless you supply a resolver. The cache is an unbounded Map that is never cleared, so memoizing a function called with many distinct inputs (user IDs on a server, for instance) is a memory leak; getUser.cache.clear() or a replacement cache with a size limit (_.memoize.Cache = ...) is the escape hatch. And for async functions, the cache stores the promise, including a rejected one, so a single failed request is cached as a permanent failure. Delete the entry on rejection (getUser.cache.delete(key)) if retries should work.
Curry
// Curry (partial application)
const add = (a, b, c) => a + b + c;
const curriedAdd = _.curry(add);
curriedAdd(1)(2)(3); // 6
curriedAdd(1, 2)(3); // 6
curriedAdd(1)(2, 3); // 6
// Useful for reusable functions
const add5 = curriedAdd(5);
add5(10, 15); // 30
curry decides how many arguments to wait for from the function’s length, which does not count default parameters or rest parameters, so currying (a, b = 1) => ... waits for only one argument. Currying fits a data-last functional style, and Lodash’s main API is data-first (_.map(collection, fn)), which is why lodash/fp exists with auto-curried, data-last variants. In everyday code, a small arrow function (const add5 = (b, c) => add(5, b, c)) is usually clearer.
String Methods
// Case conversion
_.camelCase('hello world'); // 'helloWorld'
_.snakeCase('helloWorld'); // 'hello_world'
_.kebabCase('helloWorld'); // 'hello-world'
_.startCase('hello world'); // 'Hello World'
// Truncate
_.truncate('This is a very long string', { length: 15 });
// 'This is a ve...'
// Pad
_.pad('hi', 8); // ' hi '
_.padStart('5', 3, '0'); // '005'
_.padEnd('5', 3, '0'); // '500'
// Trim
_.trim(' hello '); // 'hello'
_.trimStart(' hello '); // 'hello '
_.trimEnd(' hello '); // ' hello'
The case converters split words on spaces, punctuation and case changes, so they also normalize mixed input: _.camelCase('--foo-bar--') is 'fooBar' and _.snakeCase('XMLHttpRequest') is 'xml_http_request'. That makes them handy for mapping API field names between snake_case and camelCase. pad, trim and friends mostly duplicate native padStart, padEnd and trim; the Lodash versions accept null and custom characters to trim.
Number Methods
// Random
_.random(1, 10); // Random int between 1 and 10
_.random(1.5, 5.5, true); // Random float
// Clamp (constrain to range)
_.clamp(10, 0, 5); // 5
_.clamp(-5, 0, 10); // 0
_.clamp(3, 0, 10); // 3
// In range
_.inRange(3, 2, 4); // true
_.inRange(5, 8); // true (0 to 8)
Watch the boundaries: _.random(1, 10) includes both ends, while _.inRange(n, start, end) includes start but excludes end, so _.inRange(4, 2, 4) is false. _.random uses Math.random, which is fine for UI and sampling but not for tokens, passwords or anything security-related; use crypto.getRandomValues or crypto.randomUUID there.
Real-World Examples
API Request with Debounce
import { useState, useMemo, useEffect } from 'react';
import debounce from 'lodash/debounce';
import axios from 'axios';
const searchAPI = async (query) => {
const response = await axios.get('/api/search', {
params: { q: query }
});
return response.data;
};
// React example
function SearchInput() {
const [results, setResults] = useState([]);
// One debounced function per component instance; the side effect lives inside it
const debouncedSearch = useMemo(
() => debounce(async (query) => setResults(await searchAPI(query)), 300),
[]
);
useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]);
const handleChange = (e) => {
const query = e.target.value;
if (query.length > 2) debouncedSearch(query);
};
return <input onChange={handleChange} />;
}
A tempting variant, const data = await debouncedSearch(query); setResults(data);, is a common mistake: a debounced function returns the result of the last completed invocation, so the calls made while typing get undefined or a stale promise, and results appear out of sync with the input. Putting setResults inside the debounced function fixes that. useMemo keeps one debounced instance across renders (creating it in the render body would make a new timer on every keystroke), and the effect cleanup cancels a pending call when the component unmounts. One problem debouncing does not solve is out-of-order responses: if a slow response for “rea” arrives after the one for “react”, the old results win. Aborting the previous request with an AbortController, or ignoring responses whose query is no longer current, closes that gap.
Data Transformation
import { groupBy, mapValues, sortBy } from 'lodash';
const orders = [
{ id: 1, customer: 'Alice', amount: 100, status: 'completed' },
{ id: 2, customer: 'Bob', amount: 50, status: 'pending' },
{ id: 3, customer: 'Alice', amount: 200, status: 'completed' },
];
// Group by customer and sum amounts
const customerTotals = mapValues(
groupBy(orders, 'customer'),
(orders) => orders.reduce((sum, order) => sum + order.amount, 0)
);
// { Alice: 300, Bob: 50 }
// Get top customers
const topCustomers = sortBy(
Object.entries(customerTotals),
([_, amount]) => -amount
);
// [['Alice', 300], ['Bob', 50]]
Note the named import from 'lodash' at the top: with the CommonJS lodash package this pulls in the entire library, so in a browser bundle prefer lodash-es or per-method imports. groupBy followed by mapValues is the classic “aggregate per key” pattern; with native code, a single reduce into an object, or Object.groupBy in ES2024 environments, does the same. The -amount key is how sortBy sorts descending; orderBy(entries, [1], ['desc']) says it more directly.
Safe Object Access
import get from 'lodash/get';
// API response
const response = {
data: {
user: {
profile: {
email: '[email protected]'
}
}
}
};
// Safe access (no errors if path doesn't exist)
const email = get(response, 'data.user.profile.email');
const phone = get(response, 'data.user.profile.phone', 'N/A');
// Native equivalent (preferred in new code):
// const email = response?.data?.user?.profile?.email;
// const phone = response?.data?.user?.profile?.phone ?? 'N/A';
With optional chaining available everywhere that matters, the native form is shorter, faster, and type-checked in TypeScript, whereas a string path passed to get is invisible to the type checker and to rename refactoring. Keep _.get for dynamic paths.
TypeScript Support
import { debounce, cloneDeep } from 'lodash';
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
// Type-safe clone
const cloned: User[] = cloneDeep(users);
// Type-safe debounce
const handleSearch = debounce((query: string) => {
console.log(query);
}, 300);
Types come from the separate @types/lodash package (npm install -D @types/lodash, or @types/lodash-es). They are good for most functions: debounce preserves the parameter types and adds cancel/flush, and cloneDeep returns the input type. They get weaker for string-path functions: _.get(obj, 'a.b') can infer types for literal paths in simple cases but falls back to any or a loose type for dynamic ones, and _.set does not change the object’s static type at all. Chains and lodash/fp compositions also produce complex types that are hard to read in error messages.
Bundle Size Optimization
// Bad: imports entire library (~70KB)
import _ from 'lodash';
_.debounce(fn, 300);
// Good: import only what you need
import debounce from 'lodash/debounce';
debounce(fn, 300);
// Even better: use lodash-es for tree-shaking
import { debounce } from 'lodash-es';
Performance Tips
Chain Operations
// Chained
_.chain(users)
.filter({ active: true })
.sortBy('age')
.take(10)
.value();
// Step by step (same result)
let result = _.filter(users, { active: true });
result = _.sortBy(result, 'age');
result = _.take(result, 10);
Chaining is mainly a readability choice. Lodash can evaluate some chains lazily, fusing steps such as map, filter and take so that it stops after finding enough elements, but only for arrays of 200 or more elements and only for a sequence of “lazy-able” methods; sortBy must see every element, so this particular chain gains nothing. The larger cost is that _.chain depends on the whole library and defeats per-method imports and tree-shaking. For bundle-conscious code, _.flow from lodash/fp, or plain native method chains (users.filter(...).toSorted(...).slice(0, 10)), are the usual alternatives.
Reuse Functions
// Good: create once, use many times
const debouncedSearch = _.debounce(search, 300);
// Bad: create new function every time
onClick={() => _.debounce(search, 300)()}
The “bad” line never debounces anything: each click creates a new debounced function with its own timer and calls it once, so every click runs search after 300 ms. Debounce and throttle only work when the same wrapped function receives all the calls, which in React means useMemo/useRef, and in class-based or plain JavaScript means creating it once in the constructor or at module level.
Frequently Asked Questions (FAQ)
Q. Why does my _.debounce handler in a React component never seem to fire?
A. If you call _.debounce(fn, 300) directly in the component body, every render creates a brand-new debounced function with its own timer, so typing that triggers re-renders keeps handing events to fresh instances and the call you expect never settles the way you want. Create the debounced function once, for example with useMemo or useRef, so the same instance survives re-renders. Also call its .cancel() in an effect cleanup so a pending call does not run after the component unmounts.