DEV Community

Cover image for Building a User Feedback Form with React and Perseid
Matt
Matt

Posted on

Building a User Feedback Form with React and Perseid

In this guide, we'll walk through building a dynamic user feedback form using the @perseid/form library, a powerful alternative to Formik and React Hook Form. You'll see how @perseid/form makes it easy to manage form state, validation, and conditional rendering. The form we'll build will ask users to rate a service and provide feedback. Depending on the rating, it will either show a "thank you" message or prompt the user to provide additional feedback.

🚀 Let's get started!


Step 1: Setting Up the Form Configuration

The first step is to define the form configuration. This configuration outlines how the form behaves, including the fields, steps, and the flow between them. Here, we're going to create fields for a rating and a review, with conditional logic based on the user's rating. We'll also define messages for both positive and negative feedback.

Here's the configuration code:

import { type Configuration } from "@perseid/form";

const formConfiguration: Configuration = {
  // Root step-the form will start from there.
  root: "feedback",
  // Callback triggered on form submission.
  onSubmit(data) {
    alert(`Submitting the following JSON: ${JSON.stringify(data)}`);
    return Promise.resolve();
  },
  // `fields` define the data model the form is going to deal with.
  // Expect the submitted data JSON to match this schema.
  fields: {
    rating: {
      type: "integer",
      required: true,
    },
    review: {
      type: "string",
      required: true,
      // Display this field only if condition is met...
      condition: (inputs) =>
        inputs.rating !== null && (inputs.rating as number) < 3,
    },
    // Type `null` means that the value of this field will not be included in submitted data.
    submit: {
      type: "null",
      submit: true,
    },
    message_good: {
      type: "null",
    },
    message_bad: {
      type: "null",
    },
  },
  // Now that fields are defined, you can organize them in a single or multiple steps,
  // depending on the UI you want to build!
  steps: {
    feedback: {
      fields: ["rating", "review", "submit"],
      // Whether to submit the form at the end of this step.
      submit: true,
      // Next step is conditionned to previous user inputs...
      nextStep: (inputs) =>
        (inputs.rating as number) < 3 ? "thanks_bad" : "thanks_good",
    },
    thanks_good: {
      fields: ["message_good"],
    },
    thanks_bad: {
      fields: ["message_bad"],
    },
  },
};

Enter fullscreen mode Exit fullscreen mode

In this configuration:

  • The form starts at the feedback step.
  • The form contains two fields: rating (required) and review (optional unless the rating is below 3).
  • Based on the rating, the form navigates to either the "good" or "bad" feedback message.
  • Upon form submission, a simple alert is triggered with the submitted data.

The key point to grasp here is the function of the fields property. It defines the structure of the data that will be submitted, essentially acting as a data model. In contrast, the steps property outlines the form's flow, determining how these fields will be presented to the user.


Step 2: Creating the Form's React Components

Now that we have the configuration, it's time to build the actual UI that will render the form. Using @perseid/form/react, we can create custom field components to manage user interactions for each part of the form.

Here's the core React component:

import React from "react";
import Form, { type FormFieldProps } from "@perseid/form/react";

// The actual React component, used to build the UI!
function Field(props: FormFieldProps): JSX.Element {
  const { path, engine, value, status } = props;
  const [currentRating, setCurrentRating] = React.useState(0);

  // Display a different element depending on the field...

  if (path === "thanks_good.1.message_good") {
    return (
      <div className="message">
        <h1>Thanks for the feedback 🥳</h1>
        <p>We are glad you enjoyed!</p>
      </div>
    );
  }

  if (path === "thanks_bad.1.message_bad") {
    return (
      <div className="message">
        <h1>We're sorry to hear that 🥺</h1>
        <p>We'll do better next time, promise!</p>
      </div>
    );
  }

  if (path === "feedback.0.rating") {
    return (
      // Depending on the field status, define some extra classes for styling...
      <div
        className={`rating ${status === "error" ? "rating--error" : ""}`}
        onMouseLeave={() => {
          setCurrentRating((value as number | null) ?? 0);
        }}
      >
        <h1>How would you rate our service?</h1>
        {[1, 2, 3, 4, 5].map((rating) => (
          <span
            key={rating}
            className={`rating__star ${
              currentRating >= rating ? "rating__star--active" : ""
            }`}
            onMouseEnter={() => {
              setCurrentRating(rating);
            }}
            onClick={() => {
              // On click, notify the form engine about new user input.
              engine.userAction({ type: "input", path, data: rating });
            }}
          ></span>
        ))}
      </div>
    );
  }

  if (path === "feedback.0.review") {
    return (
      <div className={`review ${status === "error" ? "review--error" : ""}`}>
        <label>Could you tell us more?</label>
        <textarea
          onChange={(e) =>
            engine.userAction({ type: "input", path, data: e.target.value })
          }
        />
      </div>
    );
  }

  // path === 'feedback.0.submit'
  return (
    <button
      className="submit"
      onClick={() => {
        engine.userAction({ type: "input", path, data: true });
      }}
    >
      Submit
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here, the Field component uses the path prop to decide what to render:

  • A rating component where users can select a star rating.
  • A textarea for users to provide additional feedback.

"Thank you" messages that appear based on the rating. The form will dynamically adjust its fields and steps based on user input.

Pretty cool, right?

Almost there!


Step 3: Running the Application

Now that our form configuration and component are ready, let's integrate them into a basic React app. Here's the code to initialize and render the form:

import { createRoot, type Root } from "react-dom/client";

// Let's run the app!
let app: Root;

// Creating React root...
const container = document.querySelector("#root") as unknown as HTMLElement;
app = createRoot(container);
app.render(
  // Router is the main component for any Perseid app.
  <Form Field={Field} configuration={formConfiguration} />
);
Enter fullscreen mode Exit fullscreen mode

This code mounts the form to the DOM using React's createRoot API. The Form component, which connects our configuration and Field component, handles everything else.

Step 4: Adding Styles

Alright, we have our app logic, but if you run the code now, you'll see that it's a bit... raw 🥸

Not much to see here

So, let's pimp the form by adding some styles and animations! Below is a simple stylesheet that makes it way more appealing:

// A few animations for fun...

@keyframes swipe-out {
  0% {
    opacity: 1;
    transform: translateX(0);
  }
  75% {
    opacity: 0;
    transform: translateX(-100%);
  }
  100% {
    opacity: 0;
    transform: translateX(-100%);
  }
}

@keyframes swipe-in-one {
  0% {
    opacity: 0;
    transform: translateX(100%);
  }
  75% {
    transform: translateX(0);
  }
  100% {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes swipe-in-two {
  0% {
    opacity: 0;
    transform: translateX(0);
  }
  75% {
    transform: translateX(-100%);
  }
  100% {
    opacity: 1;
    transform: translateX(-100%);
  }
}

@keyframes bubble-in {
  0% {
    transform: scale(0.5);
  }
  75% {
    transform: scale(1.5);
  }
  100% {
    transform: scale(1);
  }
}

@keyframes fade-in {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

// Some global basic styling...

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  display: grid;
  height: 100vh;
  color: #aaaaaa;
  align-items: center;
  font-family: "Helvetica", sans-serif;
}

// And form-specific styling.

.perseid-form {
  width: 100%;
  margin: auto;

  &__steps {
    display: flex;
    overflow: hidden;
  }

  &__step {
    min-width: 100%;
    padding: 1rem 3rem;
    animation: 500ms ease-in-out forwards swipe-out;

    &__fields {
      display: grid;
      row-gap: 2rem;
    }
  }

  &__step[class*="active"]:first-child {
    animation: 500ms ease-in-out forwards swipe-in-one;
  }
  &__step[class*="active"]:last-child:not(:first-child) {
    animation: 500ms ease-in-out forwards swipe-in-two;
  }
}

.submit {
  border: none;
  cursor: pointer;
  padding: 1rem 2rem;
  border-radius: 8px;
  color: #fefefe;
  font-size: 1.25rem;
  background: #46c0b0;
  justify-self: flex-end;
  transition: all 250ms ease-in-out;

  &:hover {
    background: #4cccbb;
  }
}

.rating {
  position: relative;
  padding: 0.25rem 0;

  &__star {
    cursor: pointer;
    display: inline-block;
    font-size: 2rem;
    min-width: 2rem;
    min-height: 2rem;

    &::after {
      content: "⚪️";
    }

    &--active {
      animation: 250ms ease-in-out forwards bubble-in;
      &::after {
        content: "🌟";
      }
    }
  }

  &[class*="error"] {
    &::after {
      left: 0;
      bottom: -1.5rem;
      color: #f13232;
      position: absolute;
      font-size: 0.75rem;
      content: "👆 This field is required";
      animation: 250ms ease-in-out forwards fade-in;
    }
  }
}

.review {
  display: grid;
  row-gap: 1rem;
  position: relative;
  animation: 250ms ease-in-out forwards fade-in;

  label {
    font-size: 1.25rem;
  }

  textarea {
    resize: none;
    min-height: 5rem;
    border-radius: 8px;
    border: 1px solid #46c0b0;
    transition: all 250ms ease-in-out;
  }

  &[class*="error"] {
    &::after {
      left: 0;
      bottom: -1.5rem;
      color: #f13232;
      position: absolute;
      font-size: 0.75rem;
      content: "👆 This field is required";
      animation: 250ms ease-in-out forwards fade-in;
    }
  }
}

@media screen and (min-width: 30rem) {
  .perseid-form {
    max-width: 30rem;
  }
}
Enter fullscreen mode Exit fullscreen mode

And voilà 🤩


Conclusion

Congratulations! 🎉 You've just built a dynamic user feedback form with Perseid and React.

In this tutorial, we went over how to:

  • Define a form configuration with conditional logic.
  • Build custom React components to handle user interactions.
  • Render the form in your app and style it with animations and custom CSS.

Feel free to experiment with additional fields and steps to suit your use case. Have fun building awesome forms! 🌠


Top comments (0)