Skip to content

Tutorial: SKDB + React.js

This tutorial aims to demonstrate some key features of SKDB by walking you through creating an interactive multi-user to-do list app using SKDB and React.js.

You can either follow along with the process here or jump to the intermediate/end results in the branches of the todo-app repository.

First, you will need to start a local SKDB server and clone/run the demo todo-app, as described in the Quick-Start Guide. Before proceeding, make sure you have a dev server running with

docker run -it -p 3586:3586 skiplabs/skdb-dev-server:quickstart

and the to-do app running with

cd /path/to/todo-app && yarn install && yarn dev

Let's add a feature

Next, we'll add a new feature to the to-do list app: a "tags" system that allows users the ability to categorize and label tasks.

The skdb-react package provides React.js primitives that make it fast and easy to build reactive components backed by the synchronized database. You can either follow along with the process here or check out the tags-feature branch of the to-do list app to see the completed feature in action and the code required to implement it.

Modify the schema (server-side)

The server works with data described by a schema. In order to introduce tags, we'll first need to extend the database schema by adding the following commands to src/main.tsx

    "CREATE TABLE tags (id TEXT PRIMARY KEY, name TEXT, skdb_access TEXT);",
    "CREATE TABLE tasks_tags (task_id TEXT, tag_id TEXT, skdb_access TEXT);",

This adds a table for tags, adds a linking table to relate them to tasks. Note the skdb_access column in both tables, which SKDB will use to control read and write permissions for different users as we will discuss below.

Mirror the data locally

With SKDB, each client has an embedded local database whose data is kept in sync with remote servers automatically. Each client chooses which data it needs locally by specifying table names and/or filters.

To implement tags, we'll "mirror" both new tables in full on the client by adding them to the mirror command in src/main.tsx.

{
  table: "tags",
  expectedColumns: "(id TEXT PRIMARY KEY, name TEXT, skdb_access TEXT)",
},
{
  table: "tasks_tags",
  expectedColumns: "(task_id TEXT, tag_id TEXT, skdb_access TEXT)",
},

Create a React component

SKDB provides a useQuery primitive that can be used to build React components that are data-reactive: when the underlying data changes -- either locally or due to synchronization with other clients -- the component will automatically refresh.

The following code is all that is needed to display a list of all available tags, and can be added to src/App.tsx:

interface Tag {
  id: string;
  name: string;
}

function TagsList() {
  const tags = useQuery("SELECT * FROM tags;") as Array<Tag>;
  return (
    <Box>
      {tags.map((tag) => (
        <TagItem tag={tag} key={tag.id} />
      ))}
    </Box>
  );
}

function TagDrawer({
  mobileOpen,
  handleDrawerToggle,
}: {
  mobileOpen: boolean;
  handleDrawerToggle: () => void;
}) {
  const drawer = (
    <div>
      <AddTags />
      <Divider />
      <TagsList />
    </div>
  );
  return (
    <Box component="nav" className="tags">
      <Drawer
        variant="temporary"
        className="tags-list temp-list"
        open={mobileOpen}
        onClose={handleDrawerToggle}
        sx={{ position: "relative" }}
      >
        {drawer}
      </Drawer>
      <Drawer variant="permanent" className="tags-list perm-list" open>
        {drawer}
      </Drawer>
    </Box>
  );
}

Modify the state of the database

Modifying state with the database is also very easy: useSKDB gives you a handle on the local database which can be used to modify the data, for example when a button is pressed.

Add the following to src/App.tsx to implement components which

  1. display existing tags with a button to delete them,
    function TagItem({ tag }: { tag: Tag }) {
      const skdb = useSKDB();
    
      const del = async (tag: Tag) => {
        skdb.exec("DELETE FROM tags WHERE id = @id;", tag);
        skdb.exec("DELETE FROM tasks_tags WHERE tag_id = @id;", tag);
      };
    
      return (
        <Box className="tag">
          <Typography noWrap component="div" sx={{ flex: 1 }}>
            {tag.name}
          </Typography>
          <IconButton title="Delete" onClick={(_e) => del(tag)}>
            <DeleteIcon />
          </IconButton>
        </Box>
      );
    }
    
  2. define and create new tags using a text field,
    function AddTags() {
      const skdb = useSKDB();
      const [tagName, setTagName] = useState("");
      const isEmpty = useMemo(() => tagName.length == 0, [tagName]);
    
      const handleTagName = (e: any) => {
        setTagName(e.target.value);
      };
      const addTag = async (name: string) => {
        if (isEmpty) {
          return;
        }
        skdb.exec(
          "INSERT INTO tags (name, skdb_access) VALUES (@name, 'read-write');",
          { name },
        );
        setTagName("");
      };
    
      // 13 is keycode for enter
      const onKeyDown = ({ keyCode }: { keyCode: number }) => {
        if (keyCode == 13) addTag(tagName);
      };
      return (
        <Box className="new">
          <TextField
            placeholder="Enter the new tag name"
            label="Tag name"
            variant="standard"
            onChange={handleTagName}
            value={tagName}
            onKeyDown={onKeyDown}
          />
          <IconButton
            disabled={isEmpty}
            title="Add Tag"
            onClick={(_e) => addTag(tagName)}
          >
            <AddIcon />
          </IconButton>
        </Box>
      );
    }
    
  3. display and select tags for a given task.
    function TagDropdown({ task }: { task: Task }) {
      const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
      const open = Boolean(anchorEl);
      const handleClick = (event: React.MouseEvent<HTMLElement>) => {
        setAnchorEl(event.currentTarget);
      };
      const handleClose = () => {
        setAnchorEl(null);
      };
    
      const skdb = useSKDB();
    
      const remainingTags = useQuery(
        "SELECT * FROM tags WHERE id NOT IN (SELECT tag_id FROM tasks_tags WHERE task_id = @id);",
        task,
      );
      const handleChange = async (id: string) => {
        skdb.exec("INSERT INTO tasks_tags VALUES (@task, @tag, 'read-write');", {
          task: task.id,
          tag: id,
        });
        handleClose();
      };
      return (
        <div>
          <Button
            className="tagsdd"
            aria-haspopup="true"
            aria-expanded={open ? "true" : undefined}
            variant="outlined"
            disableElevation
            onClick={handleClick}
            startIcon={<TagIcon />}
            endIcon={<KeyboardArrowDownIcon />}
            disabled={remainingTags.length == 0}
            title="Tags"
          ></Button>
          <Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
            {remainingTags.map((tag: Tag) => (
              <MenuItem
                value={tag.id}
                key={tag.id}
                onClick={() => {
                  handleChange(tag.id);
                }}
              >
                {tag.name}
              </MenuItem>
            ))}
          </Menu>
        </div>
      );
    }
    
    function Tags({ task }: { task: Task }) {
      const skdb = useSKDB();
    
      const selectedTags = useQuery(
        "SELECT * FROM tags WHERE id IN (SELECT tag_id FROM tasks_tags WHERE task_id = @id);",
        task,
      );
      const del = (id: string) => {
        skdb.exec(
          "DELETE FROM tasks_tags WHERE task_id = @task_id AND tag_id = @tag_id;",
          { task_id: task.id, tag_id: id },
        );
      };
    
      return (
        <div className="tags">
          {selectedTags.map((tag: Tag) => {
            const handleDelete = () => del(tag.id);
            return (
              <Chip
                label={tag.name}
                key={tag.id}
                size="small"
                onDelete={handleDelete}
              />
            );
          })}
        </div>
      );
    }
    

Show the component

First, make some space on the page to display tags to the left of tasks by updating the styles for the :root selector at the top of src/App.css, changing --drawer-width from 0px to 250px and --gap from 0px to 8px.

Then, display a list of available tags by adding the following to the App function, just before the closing </div>:

<TagDrawer
  mobileOpen={mobileOpen}
  handleDrawerToggle={handleDrawerToggle}
/>

along with a button to toggle the display on narrow windows, as the first child of the <Toolbar> in the Header function:

<IconButton
  color="inherit"
  aria-label="open drawer"
  edge="start"
  onClick={handleDrawerToggle}
  sx={{ mr: 2, display: { sm: "none" } }}
>
  <MenuIcon />
</IconButton>

Finally, show the list of tags for each task by adding a Tags component as the first child of the first TableCell in TaskRow.

<Tags task={task} />
along with a dropdown to select and modify tags for each task, between that TableCell and the one containing the "Delete" button.

<TableCell className="min">
  <TagDropdown task={task} />
</TableCell>

Now, the to-do list shows a list of tags attached to each task and a list of existing tags, along with controls to add or delete tags and categorize tasks using tags.

Note that we were able to express the application logic easily using standard SQL to read and write to our local database, but SKDB is still synchronizing application state across different browser instances in real-time.

Build something more complex

Up to this point, our app has allowed any user to read and write any data, but real applications require access control -- and SKDB makes it easy with fine-grained primitives for management of permissions and privacy.

To show this in action, let's add a "like" button to tasks in our to-do list app. Users should be able to like/un-like any task and see the total number of likes on each task, but they should not be able to see exactly who liked which task, like a post multiple times, or send a like as another user.

As before, you can either follow along here or check out the likes-feature branch of the to-do list app to see the completed feature in action and the code required to implement it.

First, we will enable SKDB's dev console, which lets you easily create and switch between users during development. Replace SKDBProvider by SKDBDevConsoleProvider in the imports and rendering function of src/main.tsx.

-import { SKDBProvider } from "skdb-react";
+import { SKDBDevConsoleProvider } from "skdb-react";
       <React.StrictMode>
-        <SKDBProvider skdb={skdb}>
+        <SKDBDevConsoleProvider skdbAsRoot={skdb} create={connect}>
           <App />
-        </SKDBProvider>
+        </SKDBDevConsoleProvider>
       </React.StrictMode>,

Now, you can create and switch between users in the todo-app by using the console widget in the top right corner.

Modify the schema

First, we'll add a likes table to the database schema in src/main.tsx:

    "CREATE TABLE likes (task_id TEXT, skdb_author TEXT, skdb_access TEXT);",

Next, we'll create a reactive view on that table, counting the number of unique likes on each task. Since SKDB uses an incremental engine, this will be efficiently kept up-to-date as the underlying table's data changes.

    `CREATE REACTIVE VIEW unique_likes AS
       SELECT task_id, COUNT(*) as n FROM
         ( SELECT task_id, skdb_author FROM likes GROUP BY task_id, skdb_author )
       GROUP BY task_id;`,

Mirror both tables to the client, adding the following arguments to your call to localDb.mirror:

{
  table: "likes",
  expectedColumns: "(task_id TEXT, skdb_author TEXT, skdb_access TEXT)",
},
{ table: "unique_likes", expectedColumns: "(task_id TEXT, n INTEGER)" },

Add a Like button component

Now, we can define the following React component, which will display a Like button for a task, which is styled as either liked or unliked by the current user, displays a count of total users that have liked the given task, and can be clicked repeatedly to add/remove the current user's "like".

The logic is simple and straightforward because we're using SKDB's powerful permissions model to ensure that users can only see their own likes by setting the skdb_access field of each like to the current user uid when we insert them in the onClick callback.

function Like(task: { taskId: string }) {
  const skdb = useSKDB();

  let currUserLikes = useQuery("SELECT 1 FROM likes WHERE task_id = @taskId", {
    taskId: task.taskId,
  });

  const onClick = async () => {
    const userID = skdb.currentUser;
    if (currUserLikes.length == 0) {
      skdb.exec("INSERT INTO likes VALUES (@taskId, @userID, @userID);", {
        taskId: task.taskId,
        userID,
      });
    } else {
      skdb.exec("DELETE FROM likes WHERE task_id = @taskId", {
        taskId: task.taskId,
      });
    }
  };

  let unique_likes = useQuery(
    "SELECT n FROM unique_likes WHERE task_id = @taskId",
    { taskId: task.taskId },
  );
  let like_count = unique_likes.length == 1 ? unique_likes[0].n : 0;
  return (
    <IconButton onClick={(_e) => onClick()}>
      <Badge badgeContent={like_count} color="success">
        {currUserLikes.length == 0 ? <HeartIcon /> : <FullHeartIcon />}
      </Badge>
    </IconButton>
  );
}

Finally, add the Like button to each TaskRow, just after the tag dropdown TableCell:

<TableCell className="min">
  <Like taskId={task.id} />
</TableCell>

See permissions in action

Now, open the to-do list in two (or more) browser windows and use the SKDB developer console to create multiple users.

Playing around with the application by adding tasks and adding/removing likes, you should see the expected behaviors described at the start of this section: users can see their own likes and a count of total likes, but should neither be able to see nor modify other users' likes.

Note also that a user's state is persisted if you close and re-open the app as that user, and synchronized between multiple active instances with the same user.

Privacy and permissions

To achieve our desired privacy semantics for likes, we used some basic components of SKDB's permissions model to ensure that a user's likes can only be created or seen by that user.

This one-size-fits-all policy is easy to define, but what of more complex policies granting users varying degrees of privilege? SKDB has a powerful and fine-grained system for defining and administering privileges, which we will demonstrate in the to-do app by implementing user-controlled privacy on both tasks and tags.

As before, you can either follow along here or check out the access-control-feature branch of the to-do list app to see the completed feature in action and the code required to implement it.

Add a group-creation function

In order to restrict access to arbitrary subsets of users, we'll need to create SKDB groups.

First, let's add the following reactive view to our database schema in src/main.tsx, to make the list of users available for (read-only) mirroring.

    `CREATE REACTIVE VIEW users AS SELECT userID FROM skdb_users;`,

Next, we'll mirror that list of users, excluding the root user with a filterExpr which is interpreted analogously to a SQL WHERE clause and can be used to specify a subset of a table that you're interested in.

Group and permission-related tables (skdb_groups, skdb_group_permissions, and skdb_user_permissions) are automatically mirrored in order to enable client-side privacy checks, so don't need to be included here.

{
  table: "users",
  expectedColumns: "(userID TEXT)",
  filterExpr: "'root' <> userID",
},

Now, let's define a createGroup utility function, which we'll use to create a group owned by the current user, granting some permissions (read + write by default) to a list of members

async function createGroup(
  skdb: SKDB,
  members: string[],
  perm: string = "rw",
): Promise<string> {
  const userID = skdb.currentUser;
  const groupID = (
    await skdb.exec(
      `BEGIN TRANSACTION;
         INSERT INTO skdb_groups VALUES (id('groupID'), @userID, @userID, @userID);
         SELECT id('groupID') AS groupID;
       COMMIT;`,
      { userID },
    )
  ).scalarValue();

  skdb.exec(
    "INSERT INTO skdb_group_permissions VALUES (@groupID, @userID, skdb_permission('rw'), @userID)",
    { groupID, userID },
  );

  skdb.exec(
    "UPDATE skdb_groups SET skdb_access = @groupID WHERE groupID = @groupID;",
    { groupID },
  );

  for (const member of members) {
    skdb.exec(
      "INSERT INTO skdb_group_permissions VALUES (@groupID, @member, skdb_permission(@perm), @groupID)",
      { groupID, member, perm },
    );
  }

  return groupID;
}

The first INSERT (into skdb_groups) creates the group, specifying the current user as the exclusive owner/administrator; the second INSERT (into skdb_group_permissions) grants them read and write privileges on data whose access is restricted to the group. Then, for each member, we grant the specified permissions (read and write, by default) on said data.

Add an access-control React component

Next, we'll build a dropdown menu that can be used to select a subset accessList of other SKDB users.

interface User {
  userID: string;
}

function VisibilityDropdown({
  accessList,
  setAccessList,
}: {
  accessList: string[];
  setAccessList: (l: string[]) => void;
}) {
  const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
  const open = Boolean(anchorEl);
  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };

  const skdb = useSKDB();
  const otherUsers = useQuery("SELECT * FROM users WHERE userID <> @userID;", {
    userID: skdb.currentUser,
  });

  const handleChange = async (uuid: string) => {
    if (accessList.includes(uuid)) {
      setAccessList(accessList.filter((e) => e !== uuid));
    } else {
      accessList.push(uuid);
      setAccessList(accessList);
    }
    handleClose();
  };
  return (
    <div>
      <Badge
        anchorOrigin={{
          vertical: "bottom",
          horizontal: "right",
        }}
        badgeContent={otherUsers.length}
        color="primary"
      >
        <Badge badgeContent={accessList.length} color="success">
          <Button
            className="visibility"
            aria-haspopup="true"
            aria-expanded={open ? "true" : undefined}
            variant="outlined"
            disableElevation
            onClick={handleClick}
            startIcon={<VisibilityIcon />}
            endIcon={<KeyboardArrowDownIcon />}
            disabled={otherUsers.length == 0}
            title="Visibility"
          ></Button>
        </Badge>
      </Badge>
      <Menu
        className="users"
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
      >
        {otherUsers.map((user: User) => {
          return (
            <MenuItem
              value={user.userID}
              key={user.userID}
              onClick={() => {
                handleChange(user.userID);
              }}
            >
              <Checkbox checked={accessList.includes(user.userID)} />
              {user.userID}
            </MenuItem>
          );
        })}
      </Menu>
    </div>
  );
}

Integrate with Tag and Task creation

Now, we'll add the dropdown menu to our existing AddTasks component, allowing users to select an arbitrary subset of users who can see each task that they create by creating a group and setting the skdb_access of the created tasks to that group's ID.

 function AddTasks() {
   const skdb = useSKDB();
   const [taskName, setTaskName] = useState("");
+  const [accessList, setAccessList] = useState([]);
// ...
   const addTask = async (name: string) => {
     if (isEmpty) {
       return;
     }
+    const access_group = await createGroup(skdb, accessList);
     skdb.exec(
-      "INSERT INTO tasks (name, complete, skdb_access) VALUES (@name, 0, 'read-write');",
-      { name },
+      "INSERT INTO tasks (name, complete, skdb_access) VALUES (@name, 0, @access_group);",
+      { name, access_group },
     );
     setTaskName("");
   };
// ...
   return (
// ...
         onKeyDown={onKeyDown}
       />
+      <VisibilityDropdown {...{ accessList, setAccessList }} />
       <IconButton
         disabled={isEmpty}
// ...
   );
}
And analogously for AddTags:

 function AddTags() {
   const skdb = useSKDB();
   const [tagName, setTagName] = useState("");
+  const [accessList, setAccessList] = useState([]);

   const addTag = async (name: string) => {
     if (isEmpty) {
       return;
     }
+    const access_group = await createGroup(skdb, accessList);
     skdb.exec(
-      "INSERT INTO tags (name, skdb_access) VALUES (@name, 'read-write');",
-      { name },
+      "INSERT INTO tags (name, skdb_access) VALUES (@name, @access_group);",
+      { name, access_group },
     );
     setTagName("");
   };
// ...
   return (
// ...
         onKeyDown={onKeyDown}
       />
+      <VisibilityDropdown {...{ accessList, setAccessList }} />
       <IconButton
         disabled={isEmpty}
// ...
   );
 }

See privacy in action

Now, open the to-do list in two (or more) browser windows again, and use the developer console to connect as multiple users.

Try creating tasks and tags with varying degrees of visibility to your other users, tagging or liking tasks, and deleting tasks or tags. Each user will see snappy reactive updates, but with data and aggregations matching exactly what your permissions model dictates they should be able to access.

Conclusion

This to-do app is of course just a small example, but it should give you a sense of what is possible with SKDB -- and just how easy it makes it to build reactive applications and interactive user experiences.

We are excited to see what you build with it!