Design systems

Technical

title: Design systems created_at: 2023-04-12T14:52:16.823Z updated_at: 2023-04-12T14:52:16.823Z tldr: Building friction less systems in startups is_published: true star: true category: Technical share_type: share

Until now, I have built 2 internal design systems. Both are fast-moving startups.

The upfront investment of a design system looks big, and No PM would approve of the engineering time dedicated to it, but the time to progressively build new features decreases, totally worth it. Also, designers now cannot come up with arbitrary new designs for a new page and wreck the design.

In startups

Startups are fast-moving there is a new solution for the same old problem every day. There are new systems and pathways proposed on every iteration. When you have that many proposals, building and testing things becomes increasingly complex. There is only one way to cope with this complexity, either copy-paste old code, which is bad to begin with. Or write the whole thing from scratch, which is time-consuming.

What we can do instead is create a single source of truth. Where every developer can contribute to the system without friction.

First try

My first implementation was a total fuck up. I over-complicated implementations with a lot of unnecessary for a team of 4 engineers. I was learning about design systems and the work done was hot at the time. I was hyped and chose to do Atomic Design System with a separate internal npm package. Probably the stupidest thing I ever did. For a team of 4 frontend engineers, this was a total wreck. Every time a new component needs to be introduced a dev needs to check out a new repo and push the changes, wait for the CI to build the system, and pull the changes where required. By this time the requirements might have been changed. Not to mention they need a prior understanding of how the Atomic system worked.

With a dedicated team to support, this work, companies have built this with great success. But in an early-stage startup, this ain't flying

Do not over-engineer

Contributions without friction

Second try,

  • Put everything in a mono repo.
  • Build components based on use cases

Every engineer on the team has the same access level that they have for the production code in the same editor window. Loose restrictions on how the components were implemented. They had complete control over what needed to be coupled and de-coupled. No need for hard fast rules.

Our devs were happy. Productivity flew, Now we could push back PMs and say "Hey, with this component we have we can deliver in X days. Your new design would take us X+Y days".

At the code level

My experience in building design systems is on React framework, I am pretty sure the same can be ported to any other framework

old school prop!

Let's say we have an UserAvatar component which displays user image along with user name in toast

const UserAvatar: FC<{
  user_id: string;
  name: string;
}> = () => {
  /*...*/
};

Looks normal not so complicated. Ok new requirements lets add an option make the avatar rounded, display only the initials if image does not exist, add badges .... the complexity of the componet has increased. The prop method is not scalable anymore

Inverse the logic

Insted of component handling the fetching user image, displaying user initials, lets keep that in a hook, so components can focus on how it looks.

const RoundUserAvatar: FC<{
  user_id: string;
}> = () => {
  const { name, initials, image, shouldDisplayInitials } =
    useUserAvatar(user_id);
  /*...*/
};

const SquareUserAvatar: FC<{
  user_id: string;
}> = () => {
  const { name, initials, image, shouldDisplayInitials } =
    useUserAvatar(user_id);
  /*...*/
};

Much better, we have sparated the logic and display of the component.

Lets look at another example

const ListRoleOptions: FC = () => {
  const { currentIndex, onKeyDown, onSelect, options } = useList(values);
  /*...*/
};

we have separated list logic from the display options. Now we can create many lists at different places with the same logic.

Magical React.forwardRef and useImperativeHandle

In react we use ref to hold a DOM context of an element. React.forwardRef() is a way of child component exposing its ref to its parent.

So what can we do with ref alone, apart from dom manipulation not much. When we add in useImperativeHandle we can expose child functions and properties to the parent via ref.

Lets build a toast component

const Toast = React.forwardRef((props, fRef) => {
  const [toasts, setToasts] = React.useState([]);

  React.useImperativeHandle(fRef, () => ({
    publish: (node) => setToasts((t) => [...t, node]),
  }));

  return (
    <>
      {toasts.map((t, i) => (
        <ToastComponent key={i} fKey={i} {...props}>
          {t}
        </ToastComponent>
      ))}
    </>
  );
});

We have a Toast child component which exposes ref to the parent with publish() method

export const ToastContainer: FC = () => {
  const toastRef = useRef(null);

  const {state ...} = useUsers()

  useEffect(() => {
    if(state === 'loading-delayed') {
      toastRef.current.publish(<p> Taking a while to load users</p>)
    }
    if(state === 'errored') {
      toastRef.current.publish(<p className="errored">Loading users failed</p>)
    }
  },[state])

  return (
    <div>
      {/* {Some render} */}
      <ToastIcon
        icon={
          <BaseIcon.Bell
            stroke={theme.text.base}
            height={theme.fontSize.icon}
            width={theme.fontSize.icon}
          />
        }
        ref={onNotificationRef}
      />
    </div>
  );
};

In the above component we have invoked the child component to display a toast with just ref. No need to maintain different states.

There is one more advantage to building toasts like this. If we conditional rendering with css-animations, when mounted, the animations play fine, but when the component is unounted it breaks.

That's all for it now folks!