DEV Community

Cover image for ⚛️ Applying Strategy Pattern in React (Part 2)
Will T.
Will T.

Posted on • Edited on

⚛️ Applying Strategy Pattern in React (Part 2)

In the first part, we explored the significance of the Strategy pattern in React projects and outlined its implementation. However, the approach presented might have be a little bit of an overkill. This article aims to introduce a more simple and practical way of applying the Strategy pattern.

Example 1️⃣: Unit Conversion

Problem Statement

Consider a scenario where you need to convert weight units (e.g. converting 1000g to 1kg). A straightforward solution involves a recursive function, as shown below:

export enum Weight {
  Gram = 'g',
  Kilogram = 'kg',
  Tonne = 't',
  Megatonne = 'Mt',
  Gigatonne = 'Gt',
}

const WEIGHT_UNIT_ORDER = Object.values(Weight);
const WEIGHT_UNIT_CONVERSION_THRESHOLD = 1_000;

export const convertWeightUnit = (weightInGram: number, unit = Weight.Gram): ConvertUnitResult => {
  if (weightInGram < WEIGHT_UNIT_CONVERSION_THRESHOLD ||
  unit === WEIGHT_UNIT_ORDER.at(-1)) {
    return { newValue: weightInGram, newUnit: unit };
  }

  const nextUnit = WEIGHT_UNIT_ORDER[
    Math.min(
      WEIGHT_UNIT_ORDER.indexOf(unit) + 1, 
      WEIGHT_UNIT_ORDER.length - 1
    )
  ];
  
  return convertWeightUnit(weightInGram / WEIGHT_UNIT_CONVERSION_THRESHOLD, nextUnit);
};
Enter fullscreen mode Exit fullscreen mode

This example sets the stage for our discussion but let's not dwell on the recursive algorithm itself. Instead, let's explore how to apply this logic to another unit set, such as file size, without duplicating code. Here, the Strategy pattern offers an elegant solution.

Applying the Strategy Pattern

First, we'll have to define the additional TypeScript enum and interfaces that are needed for adding the new set of units:

  • unit.utils.types.ts:
export enum Weight {
  Gram = 'g',
  Kilogram = 'kg',
  Tonne = 't',
  Megatonne = 'Mt',
  Gigatonne = 'Gt',
}

export enum FileSize {
  Byte = 'B',
  Kilobyte = 'KB',
  Megabyte = 'MB',
  Gigabyte = 'GB',
  Terabyte = 'TB',
  Petabyte = 'PB',
}

export type GeneralUnit = Weight | FileSize;

export interface ConvertUnitResult {
  newValue: number;
  newUnit: GeneralUnit;
}

export interface UnitConversionStrategy {
  [key: string]: {
    unitOrder: GeneralUnit[];
    threshold: number;
  };
}
Enter fullscreen mode Exit fullscreen mode

Now we need to modify the code to apply the Strategy pattern. At the heart of every implementation of the Strategy pattern, there has to be an object that defines the strategies. In this case, it's UNIT_CONVERSION_STRATEGY:

  • unit.utils.ts:
import { ConvertUnitResult, FileSize, GeneralUnit, UnitConversionStrategy, Weight } from './unit.utils.types';

const UNIT_CONVERSION_STRATEGY: UnitConversionStrategy = {
  [Weight.Gram]: {
    unitOrder: Object.values(Weight),
    threshold: 1_000
  },
  [FileSize.Byte]: {
    unitOrder: Object.values(FileSize),
    threshold: 1_000
  },
};

// Populate the strategy for each unit in each category
Object.values(UNIT_CONVERSION_STRATEGY).forEach((unitStrategy) => {
  unitStrategy.unitOrder.forEach((unit) => {
    UNIT_CONVERSION_STRATEGY[unit] = unitStrategy;
  });
});

export const convertUnit = (value: number, unit: GeneralUnit): ConvertUnitResult => {
  const unitConversionStrategy = UNIT_CONVERSION_STRATEGY[unit];

  if (!unitConversionStrategy) throw new Error('Unit not supported');

  const { unitOrder, threshold } = unitConversionStrategy;

  if (value < threshold || unit === unitOrder.at(-1)) {
    return { newValue: value, newUnit: unit };
  }

  const nextUnit = unitOrder[
    Math.min(
      unitOrder.indexOf(unit) + 1,
      unitOrder.length - 1
    )
  ];

  return convertUnit(value / threshold, nextUnit);
};
Enter fullscreen mode Exit fullscreen mode

You can try it in the live codesandbox:

By leveraging the Strategy pattern as demonstrated, we circumvent the Shotgun Surgery anti-pattern. This approach keeps the number of conditional statements constant and simplifies the addition of new unit sets by merely extending the UNIT_CONVERSION_STRATEGY object without altering any existing logic, adhering to the Open/Closed Principle in SOLID.

Example 2️⃣: Design Systems in CSS-in-JS Projects

Another great example where the Strategy pattern can be applied in frontend projects is when you have to implement components in a design system.

Problem Statement

Consider implementing a button with various variants, colors, and sizes in a design system:

  • Button.types.ts:
export type ButtonVariant = 'contained' | 'outlined' | 'text';

export type ButtonColor = 'primary' | 'secondary' | 'error' | 'success' | 'warning' | 'info';

export type ButtonSize = 'xs' | 'sm' | 'md';

export interface ButtonProps {
  color?: ButtonColor;
  variant?: ButtonVariant;
  size?: ButtonSize;
  children: React.ReactNode;
}
Enter fullscreen mode Exit fullscreen mode

An implementation with simple conditionals might result in a messy component:

  • Button.tsx (Before Strategy Pattern):
import { memo } from 'react';
import { Button as ThemeUIButton } from 'theme-ui';

import { ButtonProps } from './Button.types';

const ButtonBase = ({ color = 'primary', variant = 'contained', size = 'sm', children }: ButtonProps) => {
  return (
    <ThemeUIButton
      sx={{
        outline: 'none',
        borderRadius: 4,
        transition: '0.1s all',
        cursor: 'pointer',
        ...(variant === 'contained'
          ? {
              backgroundColor: `${color}.main`,
              color: 'white',
              '&:hover': {
                backgroundColor: `${color}.dark`,
              },
            }
          : {}),
        ...(variant === 'outlined'
          ? {
              backgroundColor: 'transparent',
              color: `${color}.main`,
              border: '1px solid',
              borderColor: `${color}.main`,
              '&:hover': {
                backgroundColor: `${color}.light`,
              },
            }
          : {}),
        ...(variant === 'text'
          ? {
              backgroundColor: 'transparent',
              color: `${color}.main`,
              '&:hover': {
                backgroundColor: `${color}.light`,
              },
            }
          : {}),
        ...(size === 'xs'
          ? {
              fontSize: '0.75rem',
              padding: '8px 12px',
            }
          : {}),
        ...(size === 'sm'
          ? {
              fontSize: '0.875rem',
              padding: '12px 16px',
            }
          : {}),
        ...(size === 'md'
          ? {
              fontSize: '1rem',
              padding: '16px 24px',
            }
          : {}),
      }}
    >
      {children}
    </ThemeUIButton>
  );
};

export const Button = memo(ButtonBase);
Enter fullscreen mode Exit fullscreen mode

Applying the Strategy Pattern

Now let's try applying the Strategy pattern and see how it helps us to clear the mess:

  • Button.utils.ts:
import { ButtonColor } from './Button.types';

export const getButtonVariantMapping = (color: ButtonColor = 'primary') => {
  return {
    contained: {
      backgroundColor: `${color}.main`,
      color: 'white',
      '&:hover': {
        backgroundColor: `${color}.dark`,
      },
    },
    outlined: {
      backgroundColor: 'transparent',
      color: `${color}.main`,
      border: '1px solid',
      borderColor: `${color}.main`,
      '&:hover': {
        backgroundColor: `${color}.light`,
      },
    },
    text: {
      backgroundColor: 'transparent',
      color: `${color}.main`,
      '&:hover': {
        backgroundColor: `${color}.light`,
      },
    },
  };
};

export const BUTTON_SIZE_STYLE_MAPPING = {
  xs: {
    fontSize: '0.75rem',
    padding: '8px 12px',
  },
  sm: {
    fontSize: '0.875rem',
    padding: '12px 16px',
  },
  md: {
    fontSize: '1rem',
    padding: '16px 24px',
  },
};
Enter fullscreen mode Exit fullscreen mode
  • Button.tsx (After Strategy Pattern):
import { memo } from 'react';
import { Button as ThemeUIButton } from 'theme-ui';

import { ButtonProps } from './Button.types';
import { getButtonVariantMapping, BUTTON_SIZE_STYLE_MAPPING } from './Button.utils';

const ButtonBase = ({ color = 'primary', variant = 'contained', size = 'sm', children }: ButtonProps) => {
  const buttonVariantStyle = getButtonVariantMapping(color)[variant];

  return (
    <ThemeUIButton
      sx={{
        outline: 'none',
        borderRadius: 4,
        transition: '0.1s all',
        cursor: 'pointer',
        ...buttonVariantStyle,
        ...BUTTON_SIZE_STYLE_MAPPING[size],
      }}
    >
      {children}
    </ThemeUIButton>
  );
};

export const Button = memo(ButtonBase);
Enter fullscreen mode Exit fullscreen mode

This refactoring significantly improves code readability and gives us a clear separation of concerns.

Example 3️⃣: Verification Badge

Problem Statement

We need to create a very simple verification badge component. The requirement is as follows:

  • Low: "?" in red, showing "Low verification level" when hovered
  • Medium: "?" in orange, showing "Medium verification level" when hovered
  • High: "✓" in green, showing "High verification level" when hovered

Verification Level Badges

Without using a strategy object, we can implement the component like this:

  • VerificationLevelBadge.tsx:
import { Box, Tooltip } from '@mantine/core';

interface VerificationLevelBadgeProps {
  verificationLevel: 'high' | 'medium' | 'low';
}

export const VerificationLevelBadge = ({ verificationLevel }: VerificationLevelBadgeProps) => {
  return (
    <Tooltip
      label={
        verificationLevel === 'low'
          ? 'Low verification level'
          : verificationLevel === 'medium'
          ? 'Medium verification level'
          : 'High verification level'
      }
    >
      <Box
        component="span"
        sx={(theme) => ({
          display: 'inline-flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: theme.white,
          width: 16,
          height: 16,
          fontStyle: 'normal',
          borderRadius: '50%',
          fontSize: verificationLevel === 'high' ? '0.625rem' : '0.75rem',
          background:
            verificationLevel === 'low'
              ? theme.colors.red[5]
              : verificationLevel === 'medium'
              ? theme.colors.orange[5]
              : theme.colors.teal[5],
        })}
      >
        {verificationLevel === 'high' ? '' : '?'}
      </Box>
    </Tooltip>
  );
};
Enter fullscreen mode Exit fullscreen mode

This looks terrible. Imagine the scenario where we have to modify something, or when we have to add a new verification level.

Applying the Strategy Pattern

By introducing the strategy object, the code readability will significantly improve, and we have paved the way for future changes:

  • VerificationLevelBadge.utils.ts:
export const VERIFICATION_LEVEL_UI_MAPPING = {
  high: {
    backgroundColor: 'teal',
    text: '',
    tooltip: 'High verification level',
    fontSize: '0.625rem',
  },
  medium: {
    backgroundColor: 'orange',
    text: '?',
    tooltip: 'Medium verification level',
    fontSize: '0.75rem',
  },
  low: {
    backgroundColor: 'red',
    text: '?',
    tooltip: 'Low verification level',
    fontSize: '0.75rem',
  },
};
Enter fullscreen mode Exit fullscreen mode
  • VerificationLevelBadge.tsx:
import { Box, Tooltip } from '@mantine/core';

import { VERIFICATION_LEVEL_UI_MAPPING } from './VerificationLevelBadge.utils';

interface VerificationLevelBadgeProps {
  verificationLevel: 'high' | 'medium' | 'low';
}

export const VerificationLevelBadge = ({ verificationLevel }: VerificationLevelBadgeProps) => {
  const { tooltip, backgroundColor, text, fontSize } = VERIFICATION_LEVEL_UI_MAPPING[verificationLevel];

  return (
    <Tooltip label={tooltip}>
      <Box
        component="span"
        sx={(theme) => ({
          display: 'inline-flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: theme.white,
          width: 16,
          height: 16,
          fontStyle: 'normal',
          borderRadius: '50%',
          fontSize,
        })}
        bg={backgroundColor}
      >
        {text}
      </Box>
    </Tooltip>
  );
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

This article showcased a simplified application of the Strategy pattern in frontend projects. By adopting this pattern, we can avoid code duplication, improve code readability, and easily extend functionality without modifying existing logic.

Please look forward to the next part of the series where I'll be sharing my personal experience with applying useful design patterns in frontend projects.


If you're interested in Frontend Development and Web Development in general, follow me and check out my articles in the profile below.

Top comments (11)

Collapse
 
dscheglov profile image
Dmytro Shchehlov

@itswillt

About the conversion ...

We should go from application, not from the implementation.
Application needs the following:

interface Converter<U extends string> {
  (value: number): { value: number, unit: U }
}
Enter fullscreen mode Exit fullscreen mode

Why do we need a recursion, when logorithms work well??

const largestUnitOf =
  <U extends string>(units: readonly U[], threshould: number = 1_000): Converter<U> =>
  (value: number) => {
    const index = Math.max(
      0, 
      Math.min(
        units.length - 1,
        Math.floor(
          Math.log(value) / Math.log(threshould)
        )
      )
    );

    return { value: value / Math.pow(threshould, index), unit: units[index] }
}

const getWeightInLargestUnits = largestUnitOf(['g', 'kg', 't', 'kt', 'Mt', 'Gt'] as const);
const getFileSizeInLargestUnits = largestUnitOf(['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const, 1024);
Enter fullscreen mode Exit fullscreen mode

TS Playground

It seems strange ...

Collapse
 
itswillt profile image
Will T.

Hi, thanks for carefully reading through the article and having great feedback!

It's obvious that the logarithm solution works better. However, it's not the point of this article. Like I mentioned in the article, the recursion solution is a straightforward and intuitive one. Even a person without much knowledge about Mathematics can make it out with pure reason and logic. But again, algorithms or performance optimization are not what I want to focus on.

Thanks again for going over the article and showcasing the optimal algorithm. I never thought of using actual Math knowledge for it.

Collapse
 
dscheglov profile image
Dmytro Shchehlov

@itswillt

Firstly, I want to say that trying to write this article is a great effort.
It helps the community and the author's growth, so kudos to you.

However, it's tough to figure out the article's main point, unfortunately.

Let's talk about the title: "Applying Strategy Pattern in React" If we
switch "React" with "Angular" in your examples, does the article
mean something new?

Take the example classes like PricingStrategy; they stay exactly the same.
Even though the Pricing Cards scenario is related to the Strategy pattern,
as I mentioned in a comment on the first part of your article, this approach
might cause problems when trying to localize a site.

Two other cases: they are not about React, and they are not about the
Strategy pattern. The Strategy pattern is one way to satisfy the Open/Closed
principle, but instead of that, we end up with code close to extension without
modification.

So, if we want to illustrate how the Strategy pattern works in React, let's
concentrate on React itself. The Strategy in React has a well-known
incarnation: render/component props.

In your Pricing Card scenario, it could be a discount component that is
injected into the Pricing Card to introduce new functionality. For example,
it could prompt the user to choose how they want to receive bonuses:

  • Get a discount
  • Receive as bonuses
  • Donate to the Ukrainian Army

In various regions or during A/B testing, this could be implemented in
different ways: using a select menu, radio buttons, buttons with links, etc.

Actually A/B testing is a good concern for Strategy

If we want to discuss Strategy in specific domains, such as e-commerce
logic, unit conversion, or even CSS styling, let's focus on that. We can
use React to implement IO in the application, but it is clear that we should
not be talking about "Strategy in React."

Actually in this case, to claim that core algorithm is not a point of article
is a little bit unexpected idea.

So, I'd like to suggest to you -- separate concerns.

Collapse
 
insideee_dev profile image
insideee.dev

Cám ơn anh rất nhiều <3

Collapse
 
itswillt profile image
Will T.

Wait, you’re Vietnamese? We can exchange FB if you’d like to.

Collapse
 
insideee_dev profile image
insideee.dev

Dạ vâng, cám ơn bài viết tâm huyết của anh rất nhiều
facebook.com/share/tTjydVrD61AWBe1...

Collapse
 
docba profile image
Docba

Thank you for sharing!

Cảm ơn anh đã chia sẻ!

Collapse
 
rezk2ll profile image
Khaled Ferjani

Great read, thanks Huy

Collapse
 
longtrangit profile image
longtrangit

It's worth waiting for Part 2!

Collapse
 
quan118 profile image
Quan Nguyen

Love it!

Collapse
 
chloevu148 profile image
Chloe Vu

Like!