Hand-written vs Blackbox vs Direct-edit

Both Blackbox and Direct Edit schemes have roughly similar expressivity, and much of the decision of which to use comes down to preference. Here we compare both schemes with each other and with a “vanilla” hand-written approach (what you might write if not using Plasmic), within the context of a concrete example.

Motivating example

As an example, consider a ProfileCard component that displays a user profile in a card-like UI, and allows the current user to follow or unfollow. It may also optionally display labels associated with the user. If the user allows contact via email, it also optionally displays the email address and a “Send email” button. Lastly, if you are already following the user, the card looks blue.

You can take a look at the Profile Cards project here; feel free to make a copy for yourself so you can play with it.

Hand-written React component

A hand-written React component might look something like this:

function ProfileCard(props) {
  const {user, isFollowing, dispatch} = props;
  return (
    <div
      className=`profilecard ${isFollowing ? "profilecard--following" : ""}`
      onMouseEnter={() => dispatch("focusUser", user.id)}
      onMouseLeave={() => dispatch("focusUser", undefined)}
    >
      <Draggable>
        <div className="profilecard__avatar">
          <img className="profilecard__user_pic" src={user.image} />
          <span className="profilecard__user_name">{user.username}</span>
        </div>
      </Draggable>
      <div className="profilecard__content">
        <div className="profilecard__full_name">
          {user.fullname}
        </div>
        {user.email && user.allowContact &&
          <div className="profilecard__email">{user.email}</div>
        }
        {user.labels &&
          <div className="profilecard__labels">
            <div className="profilecard__labels_header">Labels:</div>
            <ul className="profilecard__labels_container">
              {user.labels.map(tag => <li className="profilecard__label">{label}</li>)}
            </ul>
          </div>
        }
        <div className="profilecard__actions">
          <Button
            className="btn btn--small"
            type={isFollowing ? "secondary" : "primary"}
            onClick={() => dispatch(isFollowing ? "unfollow" : "follow", user.id)}
          >
            {isFollowing ? "Unfollow" : "Follow"}
          </Button>
          {user.email && (
            <Button
              className="btn btn--small"
              onClick=() => dispatch("sendEmail", user.id)
              isDisabled={!user.allowContact}
            >
              Send email
            </Button>
          )}
          <a className="profilecard__content__actions__link" href={user.link}>
            See more
          </a>
        </div>
      </div>
    </div>
  );
}

export default ProfileCard;

Blackbox Library Scheme

In the blackbox library scheme, we strictly separate the files owned by Plasmic — responsible for presentation — and the files owned by you, the developer — responsible for behavior. Plasmic only ever updates the Plasmic-owned files, and so as component designs are updated, you can re-generate code for them without blowing away your changes.

This example uses many of the more advanced concept about the blackbox scheme here.

Initial export

As usual, our ProfileCard.tsx file starts out this way:

import * as React from "react";
import {
  PlasmicProfileCard,
  DefaultProfileCardProps
} from "./plasmic/PlasmicProfileCard";

interface ProfileCardProps extends DefaultProfileCardProps {
}

function ProfileCard(props: ProfileCardProps) {
  return <PlasmicProfileCard {...props} />;
}
export default ProfileCard;

Updated wrapper component

We want to accomplish a few things here:

  • Instead of directly exposing the DefaultProfileCardProps, we instead want our ProfileCard component to take in a User as a prop.
  • We want the follow/email buttons to dispatch real actions as clicked.
  • We want to use data from User to fill the appropriate slots.

Here’s what edited wrapper component for ProfileCard might look like:

interface ProfileCardProps {
  user: User;
  isFollowing: boolean;
  dispatch: Dispatch;
}

function ProfileCard(props: ProfileCardProps) {
  const {user, isFollowing, dispatch} = props;

  // Now we render the actual design corresponding to the above variants, while allowing us to hook in real data and behavior.
  return <PlasmicProfileCard
    // VARIANTS
    // The state variant group is a single-choice variant
    state={isFollowing ? "following" : undefined}
    // The content variant group is a multi-choice variant, so any of these can be turned on or off
    content={{
      noEmail: !user.email || !user.allowContact,
      noLabels: !user.labels || user.labels.length === 0,
    }}

    // SLOTS
    // These are slots defined in the Plasmic component; we fill them with data we know from `user`
    name={user.name}
    email={user.email}
    username={user.handle}

    // OVERRIDES
    // Now we override specific elements from the Plasmic component to attach behavior or customize to fit the `user`
    // Use the user's actual profile image for the `profileImg` element
    profileImg={{
      as: "img",
      props: {
        src: user.profileImgSrc,
        alt: `Profile picture for ${user.username}`
      }
    }}

    // Let's say we want to wrap the avatar in a <Draggable/>
    avatar={{
      wrap: node => (
        <Draggable>{node}</Draggable>
      )
    }}

    followButton={{
      // FollowButton is a custom Button component, and here we are overriding props of that component for ProfileCard.  For example, we override the `onClick` prop to use a handler that follows or unfollows the user. Note that we do not need to supply an override for `type` or for `children` as we did in the previous section.  That is because the designer has already specified what those should be, depending on whether the `following` variant for the `ProfileCard` is turned on or not.
      onClick: () => dispatch(isFollowing ? "unfollow" : "follow", user.id)
    }}

    // If you just want to override the props, another convenience syntax is to just pass in an object of props directly
    emailButton={{
      onClick: () => dispatch("sendEmail", user.id),
      isDisabled: !user.allowContact
    }}

    moreLink={{
      href: user.link
    }}

    labelsContainer={{
      // We want to replace the content of the labelsContainer element with real user labels.  We use the userLabel element as the "template" here, and render one for each actual label.
      children: user.labels.map(label => (
        <PlasmicProfileCard.userLabel
          userLabel={label}
        />
      ))
    }}

    // We can specify overrides for the root element directly on PlasmicProfileCard
    onMouseEnter={() => dispatch("focusUser", user.id)}
    onMouseEnter={() => dispatch("focusUser", undefined)}
  />;
}

Note a few major differences:

  • Developers are no longer concerned with JSX, CSS styling, or static text. Instead, these are black-boxed into P``lasmic``ProfileCard, a module generated by Plasmic.
  • Developers now only focus on piping actual data (user’s names, profile image location, etc.) and attaching event handlers (clicking on the follow button) to the generated UI.
  • This separation allows designers to continue tweaking the design in Plasmic even after developers have finished their work.
  • Conditional rendering based on variants — what styles and content to render when certain variants are turned on — are handled in the Plasmic blackbox.
  • Developers have total control over how they want to define their React component; here, the React component takes in a User object, which the component then maps into variants and overrides for the presentational component.
  • Developers have a lot of control over how they want to use the generated rendering library; the Plasmic* components allow you to replace arbitrary subtrees, or only use parts of the subtree that are useful.
  • To know what nodes can be “overriden”, the developers need to refer to the Plasmic project. If you use Typescript, we also provide typings for the renderer, so you know exactly which nodes are available.

Direct Edit Codegen Scheme

In this approach, we emit just one source file per component that contains a transparent JSX tree to manipulate. This more closely resembles what you might write by hand. The developer can directly edit this source file. The generated code is initially almost entirely presentational, and the developer adds in the necessary logic and behavior.

In order to allow further design changes after you have made edits, this approach relies on language tooling over the generated files—we restrict the type of edits, so that we can cleanly “rebase” your edits on top of the generated source. (This is more fine-grained than a traditional line-based patch, as we are aware of JSX syntax.)

Read more about Direct-edit scheme here.

Initial export

This would be the initially generated code:

// ProfileCard.tsx
interface ProfileCardProps {
  name?: React.ReactNode;
  email?: React.ReactNode;
  username?: React.ReactNode;
  isFollowing?: MultiChoiceArg<"isFollowing">;
  content?: MultiChoiceArg<"noLabels" | "noEmail">;
  // Required className prop is used for positioning this component
  className?: string;
}

function ProfileCard(props: ProfileCardProps) {
  const variants: PlasmicProfileCard__VariantsArgs = {
    isFollowing: props.isFollowing,
    content: props.content
  };
  const args: PlasmicProfileCard__ArgsType = {
    name: props.name,
    email: props.email,
    username: props.username
  };
  const rh = new PlasmicProfileCard__RenderHelper(
    variants,
    args,
    props.className
  );
  // plasmic-managed-jsx/33
  return (
    <Stack.div className={rh.cls1()}>
      <div className={rh.clsAvatar()}>
        <img className={rh.clsProfileImg()} {...rh.propsProfileImg()} />
        <div className={rh.clsUsernameContainer()}>
          <PlasmicSlot
            defaultContents={"@bart"}
            value={args.username}
            className={rh.cls$slotUsername()}
          />
        </div>
      </div>
      <Stack.div className={rh.cls5()}>
        <div className={rh.cls6()}>
          <div className={rh.clsNameContainer()}>
            <PlasmicSlot
              defaultContents={"Bart Simpson"}
              value={args.name}
              className={rh.cls$slotName()}
            />
          </div>
          {rh.showEmailContainer() && (
            <div className={rh.clsEmailContainer()}>
              <PlasmicSlot
                defaultContents={"bart@simpson.com"}
                value={args.email}
                className={rh.cls$slotEmail()}
              />
            </div>
          )}
          {rh.show9() && (
            <Stack.div className={rh.cls9()}>
              <div className={rh.cls10()}>{"Labels: "}</div>
              <Stack.div className={rh.clsLabelsContainer()}>
                <Tag {...rh.propsUserLabel()} className={rh.clsUserLabel()}>
                  Student
                </Tag>
                <Tag {...rh.props33()} className={rh.cls33()}>
                  Trouble
                </Tag>
              </Stack.div>
            </Stack.div>
          )}
        </div>
        <Stack.div className={rh.cls16()}>
          <Button {...rh.propsFollowButton()} className={rh.clsFollowButton()}>
            {rh.childStr18()}
          </Button>
          {rh.showEmailButton() && (
            <Button {...rh.propsEmailButton()} className={rh.clsEmailButton()}>
              Send Email
            </Button>
          )}
          <a className={rh.clsMoreLink()} {...rh.propsMoreLink()}>
            See more
          </a>
        </Stack.div>
      </Stack.div>
    </Stack.div>
  );
}

Note the component file here; you can clearly see the JSX tree for this component, as well as the various conditions for showing / hiding elements. Some details are still behind the rh helper, but much more is clearly inspectable compared to the blackbox scheme.

Updated component

Now we can go ahead and attach our data and logic to this JSX tree. The direct-edit scheme depends on us only making certain kinds of edits that it understands, so that when we want to update the component with new designs, it knows how to merge in the new code with your edits.

Here are the equivalent edits to make:

// ProfileCard.tsx
interface ProfileCardProps {  user: User;  isFollowing: boolean;  dispatch: Dispatch;  className?: string;}
function ProfileCard(props) {
  // We compute what variants to turn on based on our props.  const {user, isFollowing, isFriend, dispatch, className} = props;  const variants: PlasmicProfileCard__VariantsArgs = {
    isFollowing: isFollowing,    content: {      noEmail: !user.email || !user.allowContact,      noLabels: !user.labels || user.labels.length === 0,    }  };
  const args: PlasmicProfileCard__ArgsType = {
    name: user.name,    email: user.email,    username: user.handle  };
  const rh = new PlasmicProfileCard__RenderHelper(
    variants,
    args,
    className
  );
  // plasmic-managed-jsx/33
  return (
    <Stack.div
      className={rh.cls1()}
      onMouseEnter={() => dispatch("focusUser", user.id)}      onMouseLeave={() => dispatch("focusUser", undefined)}    >
      {/* You are allowed to wrap elements in other components */}      <Draggable>        <div className={rh.clsAvatar()}>
          {/* You are allowed to add props to generated code to attach real data */}          <img
            className={rh.clsProfileImg()}
            {...rh.propsProfileImg()}
            src={user.profileImgSrc}          />
          <div className={rh.clsUsernameContainer()}>
            <PlasmicSlot
              defaultContents={"@bart"}
              value={args.username}
              className={rh.cls$slotUsername()}
            />
          </div>
        </div>
      </Draggable>      <Stack.div className={rh.cls5()}>
        <div className={rh.cls6()}>
          <div className={rh.clsNameContainer()}>
            <PlasmicSlot
              defaultContents={"Bart Simpson"}
              value={args.name}
              className={rh.cls$slotName()}
            />
          </div>
          {rh.showEmailContainer() && (
            <div className={rh.clsEmailContainer()}>
              <PlasmicSlot
                defaultContents={"bart@simpson.com"}
                value={args.email}
                className={rh.cls$slotEmail()}
              />
            </div>
          )}
          {rh.show9() && (
            <Stack.div className={rh.cls9()}>
              <div className={rh.cls10()}>{"Labels: "}</div>
              <Stack.div className={rh.clsLabelsContainer()}>
                {/* You can use a "real" list here to generate list of elements */}                {user.labels.map(label =>                  <Tag {...rh.propsUserLabel()} className={rh.clsUserLabel()}>                    {label}                  </Tag>                )}              </Stack.div>
            </Stack.div>
          )}
        </div>
        <Stack.div className={rh.cls16()}>
          <Button
            {...rh.propsFollowButton()}
            className={rh.clsFollowButton()}
            onClick={() => {              dispatch(isFollowing ? "unfollow" : "follow", user.id)            })          >
            {rh.childStr18()}
          </Button>
          {rh.showEmailButton() && (
            <Button
              {...rh.propsEmailButton()}
              className={rh.clsEmailButton()}
              onClick={() => dispatch("sendEmail", user.id)}              isDisabled={!user.allowContact}            >
              Send Email
            </Button>
          )}
          <a className={rh.clsMoreLink()} {...rh.propsMoreLink()} href={user.link}>            See more
          </a>
        </Stack.div>
      </Stack.div>
    </Stack.div>
  );
}

export default ProfileCard;

Compared to the blackbox scheme, this approach makes working with Plasmic-generated code more transparent and familiar (looks like normal-ish React code), at the cost that you are constrained in the kind of edits you can make to this code to attach your behavior and business logic.