UI builder end-to-end tutorial: building Minitwitter

This tutorial will show building a simple app, Minitwitter, end-to-end.

This is about using Plasmic as a UI builder—a more rare and advanced mode of using Plasmic! Users more commonly want to use Plasmic as an app builder.

About Minitwitter

Minitwitter is a very basic web app—its main page is just a wall of posts. There’s also a second page where anyone can enter a new post. There’s no login/auth or anything else. So it’s a great way to get started learning about all the core Plasmic concepts and workflow quickly, without getting bogged down in complexity that’s specific to any particular app or domain. It’s simpler even than the TodoMVC tutorial!

You can play with a finished version of the codebase here:

About this tutorial

This tutorial consists of two parts—design and code. If you want to just focus on the code, you can skip the design portion, and instead clone this completed Plasmic project (however, we still recommend the video tutorial as a primer to the main concepts in Plasmic, such as slots and variants).

https://studio.plasmic.app/projects/gT4hmyT4Gfq9iSDYjENowj

In the design section, you will learn the following concepts in Plasmic Studio:

  • Free-form drawing and wireframing
  • Artboards
  • Components
  • Layout
  • Slots
  • Variants, including interaction variants
  • Live preview mode

In the code section, you will:

  • Import Plasmic components into a React codebase.
  • Wire props up to the components, integrating with real React state.
  • Control what interfaces the components export.
  • Add multiple pages and routing.
  • Load and store data into real data stores.

This tutorial covers the codegen mode of integrating Plasmic into a codebase, not PlasmicLoader. Codegen is recommended for when you’ll need a lot of interactivity or otherwise plan to hook up your components to a lot of code. For websites with mostly content and only light interactivity, we recommend using PlasmicLoader. Learn more about the distinction.

You can find a completed version of the codebase here if you wish to refer to it:

https://github.com/plasmicapp/minitwitter

Design Minitwitter

Let’s begin with a video showing how to use Plasmic Studio to design the Minitwitter app, learning all the core concepts in Plasmic as we go:

Again, if you wish to skip this, you can clone the completed design project for the following code sections of the tutorial:

https://studio.plasmic.app/projects/gT4hmyT4Gfq9iSDYjENowj

Import the design into code

Create a new React app using create-react-app. In this tutorial we’ll be showing Typescript, but Plasmic also works with plain JS projects.

Copy
npx create-react-app minitwitter --typescript
cd minitwitter/
yarn start

First, install the CLI:

Copy
yarn global add @plasmicapp/cli
# or: npm install -g @plasmicapp/cli

Next, sync the Plasmic components into your codebase. The first time you run this, you’ll be prompted to authenticate and accept some default settings. The PROJECT_ID is the ID in your URL (https://studio.plasmic.app/projects/PROJECT_ID).

Copy
plasmic sync -p PROJECT_ID

You now have a src/components/ directory, and a src/components/plasmic/ directory inside that.

Just render the static design in our app:

Copy
import React from 'react';
import Feed from './components/Feed';
function App() {
return <Feed />;
}
export default App;

Now if you check your running React app, you should see the Feed page!

First glance at Plasmic components

Let’s look inside the Feed component:

Copy
// This is a skeleton starter React component generated by Plasmic.
// This file is owned by you, feel free to edit as you see fit.
import * as React from 'react';
import { PlasmicFeed, DefaultFeedProps } from './plasmic/minitwitter/PlasmicFeed';
// Your component props start with props for variants and slots you defined
// in Plasmic, but you can add more here, like event handlers that you can
// attach to named nodes in your component.
//
// If you don't want to expose certain variants or slots as a prop, you can use
// Omit to hide them:
//
// interface FeedProps extends Omit<DefaultFeedProps, "hideProps1"|"hideProp2"> {
// // etc.
// }
//
// You can also stop extending from DefaultFeedProps altogether and have
// total control over the props for your component.
interface FeedProps extends DefaultFeedProps {
children?: never;
}
function Feed(props: FeedProps) {
// Use PlasmicFeed to render this component as it was
// designed in Plasmic, by activating the appropriate variants,
// attaching the appropriate event handlers, etc. You
// can also install whatever React hooks you need here to manage state or
// fetch data.
//
// Props you can pass into PlasmicFeed are:
// 1. Variants you want to activate,
// 2. Contents for slots you want to fill,
// 3. Overrides for any named node in the component to attach behavior and data,
// 4. Props to set on the root node.
//
// By default, we are just piping all FeedProps here, but feel free
// to do whatever works for you.
return <PlasmicFeed {...props} />;
}
export default Feed;

If you clear out the comments, the code is quite minimal:

Copy
import * as React from 'react';
import { PlasmicFeed, DefaultFeedProps } from './plasmic/minitwitter/PlasmicFeed';
interface FeedProps extends DefaultFeedProps {
children?: never;
}
function Feed(props: FeedProps) {
return <PlasmicFeed {...props} />;
}
export default Feed;

This is showing the codegen mode of using Plasmic. In this, Plasmic generates a library of dumb presentational components for you—PlasmicFeed, PlasmicNewPost, PlasmicButton, etc. These (along with the corresponding CSS and asset files they import) are owned by Plasmic, and are regenerated on sync. They handle all the rendering of the design for you, and offer a highly flexible interface that lets you attach or override props on any elements in that component, or even replace or wrap elements altogether.

Plasmic also generates skeleton wrapper components—Feed, NewPost, Button, etc. You own these files—the file above is merely the starting boilerplate that Plasmic gives you. This is where you can add your own code—state/hooks, business logic, event handlers, etc. You can do what you want with this file—for instance, you can change it to be a class component, or you can choose not to even render the Plasmic presentational component and instead render something else.

By default, the default interface (DefaultFeedProps) that wrapper components start with take as props any slots and variants you defined on the component. Importantly, you can change the interface on these components, so that you control how it works.

Show real data

Now let’s install some dependencies:

Copy
yarn add uuid moment react-router-dom
yarn add -D @types/uuid @types/react-router-dom @types/react-router

Create the data model src/model.ts:

Copy
import { v4 } from 'uuid';
export interface PostEntry {
id: string;
createdAt: Date;
content: string;
}
export function createPost(props: Omit<PostEntry, 'id'>): PostEntry {
return {
id: v4(),
...props
};
}

Make Feed take entries as props and wire that up to the presentational component.

Copy
import * as React from 'react';
import { PlasmicFeed, DefaultFeedProps } from './plasmic/minitwitter/PlasmicFeed';
import { PostEntry } from '../model';
import Post from './Post';
import moment from 'moment';
interface FeedProps extends DefaultFeedProps {
children?: never;
entries: PostEntry[];
}
function Feed({ entries, ...rest }: FeedProps) {
return (
<PlasmicFeed
{...rest}
postList={{
children: entries.map((entry) => <Post timestamp={moment(entry.createdAt).fromNow()}>{entry.content}</Post>)
}}
/>
);
}
export default Feed;

This shows how we can freely modify the interface to our component—you don’t have to stick with the default interface (DefaultFeedProps).

It also shows how the Plasmic presentational components work—they provide a very flexible interface that let you override any prop (in this case children) on any element (in this case the postList) in that presentational component.

Create some dummy entries in the top-level App component:

Copy
import React, { useState } from 'react';
import Feed from './components/Feed';
import { createPost } from './model';
function App() {
const [entries, setEntries] = useState([
createPost({ content: 'Hello world', createdAt: new Date() }),
createPost({ content: 'Another post', createdAt: new Date() })
]);
return <Feed entries={entries} />;
}
export default App;

Now refresh the page. You should see your two dummy posts show up!

Note that we used the Post component as-is, without needing to make any code changes to it or its interface.

Add posts

Now let’s make it possible to add new posts by implementing the NewPosts component:

Copy
import * as React from 'react';
import {
PlasmicNewPost,
DefaultNewPostProps
} from './plasmic/minitwitter/PlasmicNewPost';
import { createPost, PostEntry } from '../model';
import { useState } from 'react';
import { useHistory } from 'react-router';
interface NewPostProps extends DefaultNewPostProps {
children?: never;
onAdd: (entry: PostEntry) => void;
}
function NewPost({ onAdd, ...rest }: NewPostProps) {
const [content, setContent] = useState('');
const history = useHistory();
return (
<PlasmicNewPost
{...rest}
postContent={{
autoFocus: true,
value: content,
onChange: (e) => {
setContent(e.target.value);
}
}}
postButton={{
onClick: () => {
onAdd(
createPost({
content,
createdAt: new Date()
})
);
history.push('/');
}
}}
/>
);
}
export default NewPost;

You should get a build error. The Button component (which the postButton element above is an instance of) doesn’t yet take an onClick handler, so let’s add that prop to Button:

Copy
import * as React from 'react';
import {
PlasmicButton,
DefaultButtonProps
} from './plasmic/minitwitter/PlasmicButton';
interface ButtonProps extends DefaultButtonProps {
children?: never;
onClick?: () => void;
}
function Button(props: ButtonProps) {
return <PlasmicButton {...props} />;
}
export default Button;

Notice that we simply forwarded the onClick to the PlasmicButton. This is because Plasmic presentational components can take any props that the root element can take, and just forwards them onto the root element. For instance:

Copy
<PlasmicButton
// All these props can be specified and are just forwarded to the root
// element (in this case, a `button` element).
onClick={...}
title={...}
style={...}
/>

Finally, add routing to App, since we now have two screens to navigate between:

Copy
import React, { useState } from 'react';
import Feed from './components/Feed';
import { createPost } from './model';
import { Route, Switch } from 'react-router';
import { BrowserRouter } from 'react-router-dom';
import NewPost from './components/NewPost';
function App() {
const [entries, setEntries] = useState([
createPost({ content: 'Hello world', createdAt: new Date() }),
createPost({ content: 'Another post', createdAt: new Date() })
]);
return (
<BrowserRouter>
<Switch>
<Route path={'/post'}>
<NewPost onAdd={(entry) => setEntries([entry, ...entries])} />
</Route>
<Route path={'/'}>
<Feed entries={entries} />
</Route>
</Switch>
</BrowserRouter>
);
}
export default App;

Try it out!

Use localStorage

Notice that you try posting twice, the first post is gone. This is because the FAB is a normal link, and the whole page is reloaded (and all app state gone). One solution is to change the anchor to a react-router Link, which performs a fast non-page-loading transition that doesn’t lose any app state:

Copy
import * as React from 'react';
import { PlasmicFab, DefaultFabProps } from './plasmic/minitwitter/PlasmicFab';
import { Link } from 'react-router-dom';
interface FabProps extends DefaultFabProps {
children?: never;
}
function Fab(props: FabProps) {
return (
<PlasmicFab
{...props}
root={{
as: Link,
props: {
to: '/post'
}
}}
/>
);
}
export default Fab;

Here we are referencing the root element of the component (you can always refer to an unnamed root element as just root). This render function is how Plasmic lets us replace the entire element or reconfigure how it’s rendered. In this case, we don’t want a normal a tag, we want a Link from react-router. The render function is passed the original set of props that were destined for the anchor tag, and we’re rewiring them to fit what Link expects.

But still, someone could open the link in a new tab, etc. We’ll instead make the state live in some simple browser localStorage. You can of course adapt this to work with any backend of your choosing, but localStorage is easy without getting caught up in the specifics of any particular storage backend.

Copy
import React, { useState } from 'react';
import Feed from './components/Feed';
import { createPost, PostEntry } from './model';
import { Route, Switch } from 'react-router';
import { BrowserRouter } from 'react-router-dom';
import NewPost from './components/NewPost';
export function dumpPosts(entries: PostEntry[]) {
return JSON.stringify(entries);
}
export function loadPosts(json: string): PostEntry[] {
const parsed: any[] = JSON.parse(json);
for (const item of parsed) {
item.createdAt = new Date(item.createdAt);
}
return parsed;
}
function App() {
const [entries, setEntries] = useState(() => {
let loaded = localStorage['entries'];
if (loaded) {
return loadPosts(loaded);
}
return [
createPost({ content: 'Hello world', createdAt: new Date() }),
createPost({ content: 'Another post', createdAt: new Date() })
];
});
return (
<BrowserRouter>
<Switch>
<Route path={'/post'}>
<NewPost
onAdd={(entry) => {
setEntries([entry, ...entries]);
localStorage['entries'] = dumpPosts([entry, ...entries]);
}}
/>
</Route>
<Route path={'/'}>
<Feed entries={entries} />
</Route>
</Switch>
</BrowserRouter>
);
}
export default App;

Build without limits

From here on out, you can keep building this app out. Some ideas:

  • Move to a “real” data store, such as Firebase, which is a great way to get started quickly and easily.
  • Add user auth so that posts include who authored them (Firebase Auth provides an easy way to implement this).
  • Add user pages, which show the feed of posts authored by that particular user.
  • Add user following system, so that you can follow or unfollow a user from their user page. Change the home feed for a user show only posts from those who the user follows.

Nothing here is specific to Plasmic—as you can see, the extent to which you can develop your app is unbounded, and Plasmic is here to streamline all the presentational aspects of building real products.

Further learning

The intermediate code tutorial covers building TodoMVC, which is a slightly more complex app with a few more moving parts—especially with respect to variants.

The main topic that this tutorial does not cover is how to work with variants from the code. For details, see the codegen guide.

You can also explore the slightly more advanced TodoMVC tutorial (which focuses just on the code).

Was this page helpful?

Have feedback on this page? Let us know on our forum.