DEV Community

Aki Rautio
Aki Rautio

Posted on • Updated on • Originally published at akirautio.com

Decouple design from the logic with React hooks

Splitting the application logic and business logic has been long a good practice in frontend development since it eases up changing and testing each part independently. The same can be also with UX logic and design.

The idea for this writing came when I was trying to find a proper library for the date picker component in React. Most of the packages are including both application logic and design in the same package which tends to lead to a hacky solution on the project side if any customization is needed.

This can be also seen in our projects where coupling the design and the logic tightly together makes any new features to increase the component size. This tends to lead to feature-rich but huge components that are hard to test and ensure that all things work correctly.

Writing stateless components

Decoupling the logic and design starts by creating stateless components which are implementing the parts of the required design.

To make this practical, let's do a custom tab component with this logic. For the design part, we can have two components; TabItem to show a single tab and TabContainer to wrap around the tabs.


interface TabItemProps {
    onClick: (value: String) => void,
    isSelected : Boolean,
    children: String
}

const TabItem = ({
  onClick,
  isSelected,
  children,
}: TabItemProps)  => (
  <button onClick={() => onClick(children)} className={isSelected ? "selected"}>
    {children}
  </button>
)

interface TabContainerProps {
    children: React.ReactNode
}

const TabContainer = ({ children }: TabContainerProps)=> (
  <div>
    {children}
    </div>
)
Enter fullscreen mode Exit fullscreen mode

When creating these stateless components, the focus should be on how to split the functionality into smaller independent containers. There are not too many rules regarding this, and a lot of different solutions work, so the most beneficial practice is to keep consistent.

Even though the components are not including any state inside, they will hold some logic based on given properties so that they can execute required the user experience. Depending on your solution, the components can either hold the logic or just the states derived from the logic.

For example, the TabItem has isSelected property that explicitly describes the use case. The same could be done by active property that is more generic and gives the logic part more power to decide when a single tab is active.

Ensuring that stateless component looks exactly as they should, we should create tests for them. Since they don't hold internal logic, testing is a lot easier since we only need to ensure that each state works as expected. This can become by either using snapshot testing (from DOM or screenshot) with either local tools like Storybooks storyshots or Chromatic.

Adding hooks into stateless components

To make those stateless components work together, we'll create a hook that handles all the required logic. It necessary doesn't need to contain the state but it should pass all the data and actions to components.


interface Tab {
  name: String,
  value: String,
  isSelected: Boolean,
  isDisabled: Boolean?
}


interface useTabHook {
    tabList: Tab[],
    onChangeTab: (value: String) => void
    content: (selectedTab: String) => any
}

const useTabs = (tabs : Tab[], content: any) : useTabHook =>  {

    const [selectedTab, setSelectedTab] = React.useState(tabs[0]?.value)

  return {
        tabList: (tabs || []).map(item => ({ ...item, isSelected: selectedTab === item?.value })),
        onChangeTab: (value) => setSelectedTab(value),
        content: content(selectedTab)
    };
}


Enter fullscreen mode Exit fullscreen mode

The scope of the hooks should mainly to cover the logic and exclude all style related variables (classnames or inline styles). Sometimes it may make sense to add accessibility or utility properties for the styles coming from the hook.

The hooks should also consume all the external data and actions the component is needing even though some of the data goes straight to return values. Including all necessary properties to the hook makes the usage a lot easier since it's known where the data is coming, and there are no hidden requirements.

Since the hook handles all the data transformation and action execution, a developer-friendly API and composable internal parts are the keys to success. They may not be very visible when the component is relatively simple, like in our example, but once complexity increases, making an effort to the API results a huge difference.

Since we are only focusing on data transformation and actions, testing is more straight forward. There is no need to use DOM as an intermediate layer, but we can do all the purely to hooks input and out properties.
There is also a library to ease up testing the hook called react-hooks-testing-library.

Combine stateless components and the hook

Lastly, we need to combine the logic to the design by creating a component that uses the stateless component in a manner the hook defines.

interface TabsProps {
  tabs: Tab[];
  children: React.ReactNode;
}

const Tabs = ({ tabs, children }: TabsProps) => {
  const { tabList, onChangeTab, content } = useTabs(tabs, children)

  return (
    <React.Fragment>
      <TabContainer>
        <React.Fragment>
          {tabList.map(({ name, ...tab }) => (
            <TabItem {...tab} onClick={onChangeTab}>
              {name}
            </TabItem>
          ))}
        </React.Fragment>
      </TabContainer>
      {children}
    </React.Fragment>
  )
}
Enter fullscreen mode Exit fullscreen mode

Both stateless components and the hook have been tested thoroughly so the main component only needs an integration level testing to check that both elements work properly together. In our example, the tests would ensure the Tabs component is rendered properly, and the key flows are working as expected.

Advantages and disadvantages of this practice

Decoupling makes testing a lot easier since we can use the correct tools and practices for both design and logic. While logic testing is about checking outputs after certain actions, design testing is more of checking that DOM/rendered components. These need relatively different tools and testing practices so mixing them up due to coupling not only creates more tests but also creates unnecessary work for both test types.

While testing something that can be handled with coupled components, the real advantage comes when there are new requirements for either design or logic that doesn't match with already made ones. For example, you may have multiple products that are using the same codebase and have slightly different requirements for the design or the logic.

For example, in our case, if there are products with the same type of tab logic but different design, the hook part can be reused. And if one of the tabs needs a disabled that can be extended by composing a new hook with disabled logic around the current hook.

// Additional TabItem component with disabled state
const DisabledTabItem = ({
  onClick,
    isSelected,
  isDisabled,
  children,
  value
}): {
    onClick: (MouseEvent<HTMLButtonElement>) => void,
    isSelected : Boolean,
  isDisabled: Boolean,
    children: String,
  value: String
} => (
  <button onClick={onClick} value={value} disabled={isSelected}>
    {children}
  </button>
)

// Extented hook to handle disabled state
const useDisabledTabs = (input) => {
  const content = useTabs(input)

  return {
    ...content,
    onChange: (props) => {
      const tab = input.tabs.find((item) => item.value === props.target.value && item.isDisabled)
      if (tab !== undefined) {
        content.onChange(props)
      }
    },
  }
}

// Combining extra features
const TabsWithDisable = ({ tabs, children }) => {
  const { tabList, onChangeTab, content } = useDisabledTabs({
    tabs,
    content: children,
  })

  return (
    <React.Fragment>
      <TabContainer>
        {tabList.forEach(({ text, ...tab }) => (
          <DisabledTabItem {...tab} onClick={onChangeTab}>
            {text}
          </DisabledTabItem>
        ))}
      </TabContainer>
      {content}
    </React.Fragment>
  )
}
Enter fullscreen mode Exit fullscreen mode

In both the reusable parts are easy to take and only the new code needs to be tested again. This makes development a lot faster since there are no breaking changes towards already created components.

Of course, these advantages don't come for free. Decoupling the logic and design also enables one to write code on top of the existing code that increases the level of dependencies. A high dependency tree will also lead to slow development if the base dependencies eventually need breaking changes. High dependency trees increase the difficulty to see the overall picture so there should be a balance between building on top and refactoring the current code.

Examples

I have been happy to see that this practices has gotten more momentum lately and there are pretty good production ready packages to use.

Datepicker hooks

First package I have been seen using this is @datepicker-react/hooks. There is also styled-components package for design but the hooks part can be used separately.

Adobe's React Spectrum

React Spectrum takes this even further by a hook library for both accessibility and logic for the most common use cases.

If you know any more like this please write a comment! I would so much want know if there are more package like this.

Summary

Decoupling design and logic can be done with a hook and stateless components. This enables creating new components based on the already written logic or design and test both logic and design separately.

Oldest comments (0)