Memoization in JavaScript: Optimising Function Calls

Hero image for Memoization in JavaScript: Optimising Function Calls. Image by Fredy Jacob.
Hero image for 'Memoization in JavaScript: Optimising Function Calls.' Image by Fredy Jacob.

When writing JavaScript, we sometimes encounter situations where functions are called repeatedly with the same inputs, potentially performing expensive calculations each time. This repetition wastes resources, slows down our applications, and makes our code less efficient.

Memoization is a straightforward technique that solves this problem by caching results. In simple terms, it 'remembers' the outcome of previous function calls, so future calls with the same inputs can return instantly without recalculating anything.

In this article, I intend to explain clearly what memoization is, show you how it works, and demonstrate how you can use it practically to improve your JavaScript functions. And yes, 'memoization' might look suspiciously like an American spelling, but don't worry, there's no 'memoisation' version of the word to worry about. Even British developers have agreed to leave this one alone.


What Exactly is Memoization?

Memoization is simply a method of caching the output of functions. If you call a memoized function with the same arguments again, it quickly retrieves the stored result rather than recalculating it from scratch.

It's particularly useful when working with computationally expensive or recursive functions, as it reduces unnecessary repeated work.


A Practical Example of Memoization

Imagine we have a function that calculates Fibonacci numbers, a classic example that's very slow without optimisation. Here's a simple, unoptimised Fibonacci function:

const fibonacci = (n: number): number => {  if (n <= 1) return n;  return fibonacci(n - 1) + fibonacci(n - 2);};

If you call fibonacci(40), you'll quickly notice that it is extremely slow. Why? Because it repeats the same calculations repeatedly.

We can optimise this using memoization.

Optimising with Memoization

Here's the same Fibonacci function, now optimised clearly with memoization:

const fibonacciMemoized = () => {  const cache: Record<number, number> = {};  const fib = (n: number): number => {    if (n <= 1) return n;        if (cache[n]) return cache[n];    cache[n] = fib(n - 1) + fib(n - 2);    return cache[n];  };  return fib;};const fibonacci = fibonacciMemoized();console.log(fibonacci(40));  // Much faster now

How Memoization Works Clearly Explained

Memoization essentially does two simple things:

  • Checks if the result for given inputs is already stored in the cache.
  • If it's there, it returns the cached value immediately.
  • If it's not cached, it calculates, stores, and returns the new result.

This approach ensures that expensive computations are only performed once, significantly improving performance for repeated function calls.


When Does Memoization Actually Help?

Memoization isn't always necessary, and can actually introduce overheads that slow your application down if applied to too many functions that simply don't need it. However, it really shines when:

  • Your function takes noticeable time or resources to calculate.
  • The function often runs with the same input arguments.
  • Your inputs are straightforward (e.g., numbers, strings), making them easy to cache.

Practical uses include:

  • Recursive algorithms (like our Fibonacci example).
  • Heavy data transformations or mathsheavy calculations.
  • Caching responses from APIs or database queries.

If your function already runs quickly, adding memoization will probably not help much and might even complicate things unnecessarily.


Creating a Simple Memoization Utility

To make memoization convenient, we can create a reusable function like this:

const memoize = <T extends (...args: any[]) => any>(fn: T): T => {  const cache = new Map<string, ReturnType<T>>();  return ((...args: Parameters<T>) => {    const key = JSON.stringify(args);    if (cache.has(key)) {      return cache.get(key);    }    const result = fn(...args);    cache.set(key, result);    return result;  }) as T;};// Example useconst slowMultiply = (a: number, b: number) => {  console.log('Calculating...');  return a * b;};const fastMultiply = memoize(slowMultiply);console.log(fastMultiply(2, 3));  // 'Calculating...' and then 6console.log(fastMultiply(2, 3));  // instantly returns 6 from cache

This is not dissimilar to how React does it...


Memoization in React

If you're building applications with React, you've probably encountered cases where components unnecessarily rerender, causing slow performance or laggy user interfaces. Thankfully, React provides builtin memoization tools to tackle this issue directly; namely useMemo and useCallback.

Using useMemo

useMemo lets us cache the results of expensive calculations within components. Here's an example:

import { useMemo } from 'react';const MyComponent = ({ data }: { data: number[] }) => {  const expensiveCalculation = useMemo(() => {    console.log('Calculating...');    return data.reduce((sum, value) => sum + value, 0);  }, [data]);  return <div>Total: {expensiveCalculation}</div>;};

With expensiveCalculation wrapped in useMemo, the calculation will only run a second time when the data prop changes. Otherwise, React instantly returns the cached result instead.

Using useCallback

useCallback is similar, but it memoizes entire functions rather than their results. This stops React from unnecessarily recreating functions on every render, which can be particularly useful when passing functions down as props:

import { useCallback } from 'react';const ButtonComponent = ({ onClick }: { onClick: () => void }) => (  <button onClick={onClick}>Click me</button>);const ParentComponent = () => {  const handleClick = useCallback(() => {    console.log('Button clicked!');  }, []);  // function never changes  return <ButtonComponent onClick={handleClick} />;};

With useCallback, React understands that the handleClick function should remain stable across renders, avoiding unnecessary rerenders of ButtonComponent.

Using React's memo()

Taking things one step further still, another tool that React provides for memoization is the memo() function, which lets you memoize entire components. Wrapping a component in memo() tells React to skip unnecessary rerenders of the entire component if the props haven't changed.

For example:

import { memo } from 'react';type MyComponentProps = {  value: number;};const MyComponent = ({ value }: MyComponentProps) => {  console.log('Rendering MyComponent');  return <div>Value is {value}</div>;};export default memo(MyComponent);

With this, React only rerenders MyComponent if its props actually change. This approach is particularly helpful for components that don't often change but find themselves caught in frequent rerender cycles due to changes higher up in the component tree.

This simple wrapper can noticeably improve your app's performance without complicating your code.

When to Use These Hooks

You won't always need these hooks. But if you notice:

  • Slow or jerky UI when components rerender repeatedly.
  • Heavy calculations running every render.
  • Child components frequently rerendering unnecessarily.

That's exactly the moment to bring in React's memoization hooks.


Does Memoization Improve Performance Significantly?

Memoization can give you a big speed boost, but there's always a tradeoff:

  • Speed improvement:

    Future calls with identical arguments become practically instant.
  • Memory usage:

    Results are stored, slightly increasing memory usage.

Most of the time, this extra memory use is minimal and worth the performance gain. But always keep an eye on memory if you're memoizing large datasets.


Wrapping up

Memoization is genuinely useful for making slow, repetitive tasks run much faster. By caching previous function results, we reduce wasted effort and speed up our apps significantly. Once you're comfortable with memoization, you'll spot plenty of opportunities to optimise your code clearly and effectively.

Key Takeaways

  • Memoization caches function results to prevent redundant calculations.
  • It's perfect for expensive functions with repeated calls.
  • Keep an eye on memory use—memoization adds slight overhead.
  • It's simple and easy to implement with a reusable utility.

With this clear understanding of memoization, you'll be able to make your JavaScript code faster and smoother in no time.


Categories:

  1. Development
  2. Front‑End Development
  3. Guides
  4. JavaScript
  5. Performance
  6. React