Components: Direct-Edit Scheme

The “direct edit” codegen scheme aims to give transparent access to a plain React JSX tree. This component code more closely resembles what you might traditionally write by hand, exposing the internal structure of the code. The developer can directly edit this tree, attaching props to elements, adding or removing nodes, and wrapping elements in nodes.

By our guiding principles, the direct edit scheme allows you to:

  • Update components with new designs by merging edits to the code with edits to the design every time you run plasmic sync. This is accomplished by language tooling that effectively rebases code edits on top of the latest generated presentational code—similar to a line-based rebase similar to a git line-based rebase, but with more precise awareness of JSX syntax. This is possible because Plasmic tracks the historical versions of designs and their generated code, so it can perform a three-way diff with code edits.
  • Control component props by giving you total freedom in defining what props your component take.
  • Instrument components to do anything by allowing you to directly and naturally edit the JSX tree. This includes attaching props to elements, wrapping elements in other elements (such as behavioral wrapper components), adding and removing elements altogether, and more sophisticated transformations such as wrapping in loops and conditionals and IIFEs. Besides edits to the JSX tree, you may also change the preamble of the component, you may use React hooks as you usually would to manage state and fetch data.

Generated files

For each component in the Plasmic project, we emit two source files:

  • Component file (e.g. Button.tsx) — This contains the transparent JSX tree to manipulate that the developer can directly edit. The generated code is initially almost entirely presentational, and the developer adds in the necessary logic and behavior.
  • Helper file (e.g. plasmic/PlasmicButton.tsx) — This source file contains the default design props and class names that are attached to JSX elements. Plasmic separates these out from the JSX tree, rather than inlining them into the tree, in order to keep the tree easy to read. These details are organized into a RenderHelper class that the component file uses. This file, like other files in the plasmic directory, are managed and regenerated by Plasmic.

In order to allow further design changes after you have made edits to the component file, this scheme relies on language tooling over the generated files—Plasmic restricts the type of edits, so that it can cleanly rebase your edits on top of the generated source.

Here is an example generated component code:

function Button(props: ButtonProps) {
  const variants: PlasmicButton__VariantsArgs = {};
  const args: PlasmicButton__ArgsType = {};
  const rh = new PlasmicButton__RenderHelper(variants, args, props.className);
  // plasmic-managed-jsx/30
  return (
    <button className={rh.clsRoot()}>
      <img className={rh.clsImg()} {...rh.propsImg()} />
      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );
}

Note that Plasmic embeds the entire JSX tree right below the // plasmic-managed-jsx/30 comment line. This line is critical for Plasmic to perform the code merge—do not change or remove it. After you update the Button component in Plasmic Studio and sync the code again, Plasmic will search for the // plasmic-managed-jsx/``... line, and merge the JSX tree below it with the updated design.

In the preamble of the function, we also see some variables defined that set up the particular configuration of the Plasmic component to render, including the variants and args (such as slot args). These then are passed to a RenderHelper class (imported from the PlasmicButton helper file), which in turn produces the props and class names for the various elements in the JSX tree, as rh.propsImg() and rh.clsImg() calls.

These RenderHelper calls are how the elements in the tree are identified by Plasmic when merging updates.

Preserved edits

Consider this Plasmic-generated Button.tsx from earlier:

function Button(props: ButtonProps) {
  const variants: PlasmicButton__VariantsArgs = {};
  const args: PlasmicButton__ArgsType = {};
  const rh = new PlasmicButton__RenderHelper(variants, args, props.className);
  // plasmic-managed-jsx/30
  return (
    <button className={rh.clsRoot()}>
      <img className={rh.clsImg()} {...rh.propsImg()} />
      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );
}

Here is a non-exhaustive set of examples of edits to the JSX tree that Plasmic can merge without issue.

Add props to elements, such as event handlers:

  return (
    <button className={rh.clsRoot()} onClick={props.onClick}>      <img className={rh.clsImg()} {...rh.propsImg()} />
      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );

Overwrite default props:

  return (
    <button className={rh.clsRoot()}>
      <div className={rh.clsImg()} {...rh.propsImg()} src={...} />      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );

Change tags:

  return (
    <button className={rh.clsRoot()}>
      <img className={rh.clsImg()} {...rh.propsImg()} />
      <span className={rh.clsBox()}>Click Me</span>    </button>
  );

Edit text content:

  return (
    <button className={rh.clsRoot()}>
      <img className={rh.clsImg()} {...rh.propsImg()} />
      <div className={rh.clsBox()}>Start Free Trial</div>    </button>
  );

Insert new elements:

  return (
    <button className={rh.clsRoot()}>
      <img className={rh.clsImg()} {...rh.propsImg()} />
      <div className={rh.clsBox()}>Click Me</div>
      <Modal />    </button>
  );

Remove elements:

  return (
    <button className={rh.clsRoot()}>
      {/* (We removed the img) */}      <div className={rh.clsBox()}>Start Free Trial</div>
    </button>
  );

Edit className—you can change it to any expression containing the rh.clsXXX() call:

  return (
    <button className={[rh.clsRoot(), "myClass1"].join(" ")}>      <img className={rh.clsImg()} {...rh.propsImg()} />
      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );

Wrap an existing element in another element:

  return (
    <button className={rh.clsRoot()}>
      <img className={rh.clsImg()} {...rh.propsImg()} />
      <FancyTooltip title="Surprise">        <div className={rh.clsBox()}>Click Me</div>
      </FancyTooltip>    </button>
  );

  // Or: use a wrapper component that takes a render expression.

  return (
    <button className={rh.clsRoot()}>
      <img
        key={imgId}
        className={rh.clsImg()}
        {...rh.propsImg()}
        src={makeImageUrl(imgId)}
      />
      <Stated defaultValue={0}>        {(value, setValue) =>          <div className={rh.clsBox()} onClick={() => setValue(value + 1)}>
            Clicked Me {value} Times
          </div>
        }      </Stated>    </button>
  );

Wrap elements in expressions, such as conditionals, loops, and IIFEs. This can be useful to achieve type safety or repetition:

interface ButtonProps {
  // ...
  imgId?: string;
}

function makeImageUrl(imgId: string) {
  return `https://www.images.com/${imgId}`;
}

function Button(...) {
  // ...

  // Conditionally render the image.

  return (
    <button className={rh.clsRoot()}>
      {props.imgId && (        <img          className={rh.clsImg()}          {...rh.propsImg()}          src={makeImageUrl(props.imgId)}        />      )}      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );

  // Or: loop to show a list of images.

  return (
    <button className={rh.clsRoot()}>
      {props.imgIds?.map(imgId =>        <img          key={imgId}          className={rh.clsImg()}          {...rh.propsImg()}          src={makeImageUrl(imgId)}        />      )}      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );

  // Or: wrap the image in a function call.

  return (
    <button className={rh.clsRoot()}>
      {connectDragHandler(        <img
          key={imgId}
          className={rh.clsImg()}
          {...rh.propsImg()}
          src={makeImageUrl(imgId)}
        />
      )}      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );

  // Or: wrap the image in an IIFE.

  return (
    <button className={rh.clsRoot()}>
      {(() => {        const url = makeImageUrl(imgId);        return <img
          key={url}
          className={rh.clsImg()}
          {...rh.propsImg()}
          src={url}
        />;
      })()}      <div className={rh.clsBox()}>Click Me</div>
    </button>
  );
}

Limitations

Because Plasmic needs to be able to merge edits to the code with edits to the design, it restricts the class of edits that can be made on the source file. Specifically, you cannot take entire sub-trees out of the tree and move them elsewhere. The JSX tree must remain as one in-tact expression.

(If you wish to extract sub-trees as new components, you can do this from Plasmic.)

The RenderHelper calls like rh.clsImg() and rh.propsImg() are how the elements in the tree are identified by Plasmic when merging updates, so these should also be left on the elements.

Of course, if you don’t need to iterate further on the design in Plasmic Studio, then you can simply eject from the syncing workflow and do what you will with the generated code.

RenderHelper API

Typically, Plasmic components are rendered in some specific configuration of its variants and args (such as slot args). This configuration is what is fed into the RenderHelper class.

The RenderHelper takes three arguments:

class PlasmicComponent__RenderHelper {
  constructor(
    /** The variants to activate */
    variants: PlasmicComponent__VariantsArgs,
    /** The args (such as slot args) to render with. */
    args: PlasmicComponent__ArgsType,
    /**
      * The class to forward to the root element of the class.
      * This is used by (for instance) other Plasmic components to inform
      * this component how it should be positioned or laid out.
      */
    className: string
  );
}

Variants

RenderHelper takes variants as an object mapping variant group names defined in Plasmic to the specific string name of the variant to activate for that group, or undefined. Or, for multi-choice variant groups, you can pass in an array of strings.

Example:

const [enabled, setEnabled] = useState(false);
const variants: PlasmicToggle__VariantsArgs = {
  state: enabled ? "enabled" : "disabled",
  type: props.type
};
const rh = new PlasmicToggle__RenderHelper(variants, {}, props.className);

Slots

RenderHelper takes slots as an object mapping slot name defined in Plasmic to the React Node to render into that slot.

Example:

const [text, setText] = useState("");
const args: PlasmicInput__ArgsType = {
  children: text,
  statusIcon: text === "" ? <EmptyIcon /> : null,
  label: props.label
};
const rh = new PlasmicInput__RenderHelper({}, args, props.className);