Using Ultra, the new React web framework

mangelosanto

Matt Angelosanto

Posted on May 31, 2022

Using Ultra, the new React web framework

Written by Tharaka Romesh✏️

Table of Contents

In the world of frontend development, React is one of the most popular libraries for developing components for web applications. React v18 includes new features, such as concurrent rendering, and it supports SSR with React Server Components, all of which empower web developers to create more interactive UI.

In this article, you’ll learn about a new React framework called Ultra, which uses Deno and React and focuses on using web streams and native features within the browser.

What is Ultra?

Ultra is a modern streaming React framework in Deno that leans into a browser's native features and uses ES modules, import maps, and web streams. Ultra aims to simplify your workflow by reducing the complexity of tooling, allowing developers to focus on development.

Features of Ultra

Compatibility with TypeScript and JSX

TypeScript is a popular language in the web developer community, primarily because of its optional static typing and classes and the interfaces it provides to JavaScript. Ultra is:

  • Easy to read and understand
  • Offers better support for JSX
  • Includes static type checking and better IntelliSense
  • Easily maintainable

Includes a permissions module

Because Ultra is a Deno project, it comes with all the cool features of Deno, including its permission-based security module. Ultra uses the following permissions:

  • Allow-env: allows devs to use environment variables for their project
  • Allow-read: tells the Ultra project to read the specified path
  • Allow-write: this command tells the Ultra project to write the specified path
  • Allow-net: this command adds Ultra to the list of URLs that devs use in their project

Data fetching with Ultra

Ultra uses the latest version of React, which brings more SSR and data fetching capabilities via React Suspense.

Streaming HTML in React 18

When you wrap your component with React Suspense, React doesn’t need to wait for the component to begin streaming HTML, so instead of rendering the component, React will send a placeholder, such as a spinner.

Selective hydration

Wrapping React Suspense around a component will enable selective hydration. Selective hydration will start hydrating HTML before JavaScript code loads to the browser, so the content inside the <Suspense> tags will not block the rest of the page from hydrating. If you interact with it, React will prioritize hydrating that area.

Lazy routing in Ultra

Ultra uses Wouter, a fully-fledged, lightweight, and Hooks-based routing solution for React. It also comes with a server-side integration.

Dynamic MDX

With Ultra, you can use MDX dynamically (MDX on demand) because it comes with a plugin that enables you to compile MDX on the server and run the result on the client or the frontend.

Comparing Ultra with Aleph.js

Aleph.js is a full-stack framework in Deno, used as an alternative to Next.js. Aleph.js offers features like ES module imports, file-system routing, SSR & SSG, and HMR with a fast refresh.

Ultra, however, mainly focuses on React 18’s new SSR features and treats every SSR response as a readable stream so that all data loads through React Suspense.

There is no bundling or build step in either dev or product, but we can consider Ultra as an opinionated and straightforward way to build applications with Deno and React.

The drawbacks to using Ultra

While Ultra seems like an excellent framework for working with Deno and React, it also has some drawbacks. Here are some factors to consider before starting with Ultra.

  • Ultra is still in its early stages. Ultra recently released version 1.0, and it doesn't have a fancy CLI like most frameworks.
  • Ultra doesn't support Native CSS modules or CSS Module Scripts yet, and there are no styling libraries or tools like Tailwind CSS.
  • Ultra doesn't have first-class support with Deno Deploy even though they push more toward Deno Deploy for deploying.
  • Ultra doesn't support native import maps yet, so Ultra in-lines your imports directly into the served ES modules.

Getting started with Ultra

Before starting development with Ultra, make sure you have Deno version 1.20.6+ and IDE on your machine.

Let’s create an Ultra application with create-ultra-app. Create-ultra-app is still in its early stages, so it's not a complete solution just yet. You can clone the project, which provides minimal setup to get started with Ultra.

You can find a file called importMap.json at the project's root.

{
  "imports": {
    "react": "https://esm.sh/react@18",
    "react-dom": "https://esm.sh/react-dom@18",
    "react-dom/server": "https://esm.sh/react-dom@18/server",
    "react-helmet": "https://esm.sh/react-helmet-async?deps=react@18",
    "wouter": "https://esm.sh/wouter?deps=react@18",
    "swr": "https://esm.sh/swr?deps=react@18",
    "ultra/cache": "https://deno.land/x/ultra@v0.8.0/cache.js",
    "app": "./src/app.tsx"
  }
}
Enter fullscreen mode Exit fullscreen mode

The attribute "app" refers to the entry point of the application. The rest of the attributes are the imports required to run Ultra. Another important file will be deno.json, which is the default config file Deno uses:

{
  "tasks": {
    "dev": "mode=dev deno run -A --location=http://localhost:8000 --unstable --no-check server.ts",
    "start": "deno run -A --location=http://localhost:8000 --unstable --no-check server.ts",
    "cache": "deno cache --reload server.ts",
    "vendor": "importMap=importMap.json deno run -A --unstable https://deno.land/x/ultra@v1.0.1/vendor.ts"
  },
  "importMap": "importMap.json"
}
Enter fullscreen mode Exit fullscreen mode

The tasks section defines what you can do to build, cache, or even start the development server. The most crucial part of this file is the "importMap" attributes, which specify your importMap path that holds the application entry point and dependencies. Let’s break down the rest of the code.

"Dev": this command is helpful to start the development server, and it will always force the re-importing of ESM files, enabling it to reload on save

"Start": this command is helpful in the production stage. It uses cached ESM imports and not a WebSocket reloader. It also uses whichever import map you have defined

"Cache": this command refreshes the Deno cache for server.js. It can be helpful if you run into any issues when swapping between vendor and CDN import maps

"Vendor": this is useful when you deploy the application, as it will download the dependencies into the ".ultra/x" directory and create a vendorMap.json import map file

Building components in Ultra

Let’s create a small Pokemon application that uses streaming SSR with Suspense. For this demo application, we will use the free Pokemon API. First, let’s start our project using the command deno task dev, which will spin up the Ultra development server on http://localhost:8000. If you open your browser, you will see something similar to the image below. @_@

Now, let’s create the components and pages required to build this app. Create directories called components and pages under the src directory, which will hold some common React and container components.

Let’s also add some CSS to the project. Ultra still doesn't have its own native CSS Modules, so we have to use traditional CSS in the style.css file under the src directory. Now, let's start with creating several components under src/components. Let's create two components, starting with the List.jsx component, displaying a Pokemon list.

import React from "react";
import useSWR from "swr";
import { useLocation } from "wouter";

const fetcher = (url: string) => fetch(url).then((res) => res.json());

type Pokemon = {
  name: string;
  url: string;
};

type SetLocationType = {
  (
    to: string,
    options?:
      | {
          replace?: boolean | undefined;
        }
      | undefined
  ): void;
};

const getId = (url: string): string => {
  return url.substring(url.lastIndexOf("/") - 1, url.lastIndexOf("/"));
};

const renderItems = (pokemons: Pokemon[], setLocation: SetLocationType) => {
  return pokemons?.map(({ name, url }: Pokemon) => {
    return (
      <div
        className="card"
        onClick={() => {
          setLocation(`/pokemon/${getId(url)}`);
        }}
      >
        <div className="card-body">
          <h5 className="card-title">{name}</h5>
        </div>
      </div>
    );
  });
};

const list = () => {
  const [location, setLocation] = useLocation();
  const { data, error } = useSWR(
    `https://pokeapi.co/api/v2/pokemon?limit=1000&offset=0`,
    fetcher
  );

  if (error) {
    return (
      <div className="alert alert-danger" role="alert">
        Unable to fetch data from pokemon API
      </div>
    );
  }

  return (
    <div className="card-columns">
      {renderItems(data?.results, setLocation)}
    </div>
  );
};

export default list;
Enter fullscreen mode Exit fullscreen mode

Notice that we use the useSWR hook from swr API to fetch data from the Pokemon REST API.

Next, we must create the Pokemon.jsx component, which shows the detailed information of a selected Pokemon.

import React from "react";

type MovesType = {
  move: { name: string; url: string };
  version_group_details: [];
};

type PokemonPropType = {
  name: string;
  height: number;
  weight: number;
  xp: number;
  image: string;
  moves: Array<MovesType>;
};

const renderMoves = (moves: Array<MovesType>) => {
  return moves.slice(0, 5).map(({ move }: MovesType) => {
    return <li>{move?.name}</li>;
  });
};

const Pokemon = ({ name, height, weight, image, moves }: PokemonPropType) => {
  return (
    <div className="card" style={{ width: "40rem" }}>
      <img className="card-img-top" src={image} alt="Card image cap" />
      <div className="card-body">
        <h5 className="card-title">{name}</h5>
        <h6 className="card-subtitle mb-2 text-muted">
          Height :{height} Weight: {weight}
        </h6>
        <p className="card-text">
          <ul>{renderMoves(moves)}</ul>
        </p>
      </div>
    </div>
  );
};

export default Pokemon;
Enter fullscreen mode Exit fullscreen mode

We must also create a list of berries by creating a component under the component directory called Berries.tsx.

import React from "react";
import useSWR from "swr";

type BerriesType = {
  name: string;
  url: string;
};

const fetcher = (url: string) => fetch(url).then((res) => res.json());

const getId = (url: string): string => {
  return url.substring(url.lastIndexOf("/") - 1, url.lastIndexOf("/"));
};

const renderItems = (berries: BerriesType[]) => {
  return berries?.map(({ name, url }: BerriesType) => {
    return (
      <div key={getId(url)} className="list-group-item">
        <h5 className="clickable">{name}</h5>
      </div>
    );
  });
};

const Berries = () => {
  const { data, error } = useSWR(`https://pokeapi.co/api/v2/berry`, fetcher);

  if (error) {
    return (
      <div className="alert alert-danger" role="alert">
        Unable to fetch data from pokemon API
      </div>
    );
  }

  return <div className="list-group">{renderItems(data?.results)}</div>;
};

export default Berries;
Enter fullscreen mode Exit fullscreen mode

Now, let's display all these components on the home page in the file Home.tsx under src/pages.

import React, { Suspense } from "react";
import List from "../components/List.tsx";
import Berries from "../components/Berries.tsx";

const Home = () => {
  return (
    <div className="container-fluid">
      <div className="row">
        <div className="col-md-2"></div>
        <div className="col-md-4">
          <h3>Pokemons</h3>
        </div>
        <div className="col-md-4">
          <h3>Berries</h3>
        </div>
        <div className="col-md-2"></div>
      </div>
      <div className="row">
        <div className="col-md-2"></div>
        <div className="col-md-4">
          <Suspense fallback={<div>Loading</div>}>
            <List />
          </Suspense>
        </div>
        <div className="col-md-4">
          <Suspense fallback={<div>Loading</div>}>
            <Berries />
          </Suspense>
        </div>
        <div className="col-md-2"></div>
      </div>
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

Finally, let's define the application's routes and for the home, Pokemon, and error pages.

import React from "react";
import { SWRConfig } from "swr";
import { Helmet } from "react-helmet";
import { Route, Switch } from "wouter";
import ultraCache from "ultra/cache";
import { Cache } from "https://deno.land/x/ultra/src/types.ts";

import Navigation from "./components/Navigation.tsx";
import Home from "./pages/Home.tsx";
import Selected from "./pages/Selected.tsx";

const options = (cache: Cache) => ({
  provider: () => ultraCache(cache),
  suspense: true,
});

const Ultra = ({ cache }: { cache: Cache }) => {
  return (
    <SWRConfig value={options(cache)}>
      <Helmet>
        <title>Ultra Pokemon</title>
        <link rel="stylesheet" href="/style.css" />
      </Helmet>
      <main>
        <Switch>
          <Navigation>
            <Route path="/" component={Home} />
            <Route path="/pokemon/:id" component={Selected} />
          </Navigation>
          <Route>
            <strong>404</strong>
          </Route>
        </Switch>
      </main>
    </SWRConfig>
  );
};

export default Ultra;
Enter fullscreen mode Exit fullscreen mode

Open up your browser to see something similar to this: Pokemon and Berry List

This will display two lists: one for Pokemon and the other for berries. Now that we have built a basic application let's deploy it.

Deploying an Ultra app

You can deploy an Ultra app with Docker or with Deno Deploy. With Docker, create a Docker file that supports vendored dependencies, taking deno:1.20.6+ as the base image.

Deno Deploy is a distributed serverless execution system that allows you to run JavaScript and TypeScript. It comes with V8 runtime and minimal latency and, like Cloudflare Workers, enables you to run code on edge. Ultra supports the official Deno Deploy GitHub action, which will allow you to serve static files on Deno Deploy.

To do so, create a project in the Deno Deploy Dashboard and provide the necessary to create the Deno deploy project: Deno Deploy Dashboard

Next, select the GitHub Action integration. After creating the project link in your GitHub repo, deploy it to Deno by clicking the Continue button and selecting your project from the list of repositories. Then, choose GitHub Actions as the deployment method. Git Integration

Finally, add the following to your project under .github/workflow/main.yml:

name: deno deploy
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  deploy:
    name: deploy
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Clone repository
        uses: actions/checkout@v2

      - name: Install Deno
        uses: denoland/setup-deno@main
        with:
          deno-version: 1.20.3

      - name: Build site
        run: root=https://example.com deno run -A https://deno.land/x/ultra/build.ts

      - name: Upload to Deno Deploy
        uses: denoland/deployctl@v1
        with:
          project: ultra-pokemon
          entrypoint: ULTRA.js
          root: .ultra
Enter fullscreen mode Exit fullscreen mode

Now, commit all the changes and push your code to GitHub, which will trigger GitHub Action. If everything goes as planned, you will see something like this under the Actions tab in your GitHub repo. Deploy Actions Tab

You can find the deployed link in the Upload to Deno Deploy section or in your Deno Deploy Dashboard.

Note: With the current version (v1.0) of Ultra, you will have to configure from the Deno Deploy end to get your application working. Log in to the Deno Deploy dashboard, add an environment variable called “root”, and pass the deployed URL as its value. This issue is a known bug in the current version of Ultra. Environment Variables

You can find the complete code for the above example through this GitHub repo. Also, check out the live application through this link.

Conclusion

Ultra is a great way to work with Deno and React, and its recently released version 1.0 includes many new features and improvements like ESbuild removal, integration with Markdown, and introduces create-ultra-app. However, it still has bugs, so monitor the GitHub repo for updates.

Ultra seems like a better way of building applications with Deno and React and has a great community you can find on Discord. Thanks for reading.


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on May 31, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related