Will King
Product

4 Options for saving user preferences

I am building out documentation for the Product Lab, and one of the pieces of functionality that I am running into is the ability to modify what code blocks and documentation you're seeing based on what features you want to include. Now I could set a default option and have every visit reset to that option, buuut I want to deliver an improved UX similar to how Stripe tracks what language you have selected for their documentation.

The question is how do I do it? You are probably here because you are curious about the same thing. So, let's cover our options.

Where can we store data?

  • Local Storage

  • Cookies

  • Cache (Redis or other alternatives)

  • Database

These 4 options above capture not only 4 different locations, but 4 different layers of persistence.

Local Storage

Local storage is an option provided by browsers and is only available on the client. This puts some hard limitations on the usefulness of this option as a vehicle for storing user preferences in an app, but it can be great for short term, single page use cases.

Updated use-case:

William Siota reached out with a great example of when using localStorage can be valuable. In a SPA you do not make round trip visits to a server. Everything is handle client-side. So, localStorage solves the same problem as Cookies do below when you are tracking anonymous user preferences. Cookies require trips to the server to update, but localStorage can be modified client-side.

Cookies

Cookies take what you can do with localStorage and bring it server-side. This makes cookies a really good option for storing user preferences, specifically in the case of anonymous/unauthorized users, and this is step one of my approach to tackling storing user preferences.

If I have a visitor who is either not logged in or doesn't have an account at all, I still want to provide a good experience and a user preference cookie set to expire in 10 years and you have a persistent storage when they return the next time.

Cache (Redis)

Whatever caching layer you are using they have the same benefit over using cookies and that is control over its existence. No matter if a user hops browsers or clears their browser cookies they do not have the power to clear out your cache layer.

Database

The database. This is your long term, persistent option that will guarantee your user preferences are saved for all eternity…or until you accidentally bungle a migration and need to fall back to a backup. This is the option I will be using in combination with cookies to guarantee user preferences will be available when rendering a page no matter what browser, computer, or country a user is viewing the page in.

How do we store the data?

This is where the options can be limitless. However, I am going to cover the approach I will be using and the factors that led to that decision.

Isomorphic storage solution

As, I covered in the previous section I plan on storing data in both cookies and the database. So, right out of the gate I knew I wanted to pick a solution for storing data that would work in both places exactly the same way (thus the “isomorphic” heading). Here is the expected flow for our user preference data in the app:

The solution

The limiter on how data could be stored in this scenario is the cookie. A cookie can only store a literal (string, boolean, number, etc), an array, or an object. Literal types are only useful for a single preference, arrays require you to loop through all saved values to find a single preference, sooo that narrowed it down to using a preference object pretty quickly.

Let’s go ahead and explore the solutions using objects as the data type. I am going to cover saving and searching.

Saving a user preference

This example is done in the context of a Remix app, but you can substitute for any Node based solution.

// Need to create a cookie we can use to manage anonymous preferences
export const anonUserPreferences = createCookie("anonUserPreferences", {
  httpOnly: true,
  maxAge: 60 * 60 * 24 * 365, // 1 year
  path: "/",
  sameSite: "strict",
  secrets: [getEnvOrThrow("SESSION_SECRET")],
  secure: process.env.NODE_ENV === "production",
});

// Default preferences used as fallback if cookie has been deleted by client
const defaultPreferences = {
  preferences: {
    preferenceKey: "value",
  },
};

/** 
 * Isomorphic function that saves data to the correct location based on the
 * existence of a user.
 *
 * We return an object with response and headers since the call site will need
 * to commit the cookie in the Response.
**/
async function updateUserPreference(
  request: Request,
  key: string,
  user?: User
) {
  const formData = await request.clone().formData();
  const preferenceValue = formData.get(key);

  if (typeof preferenceValue !== "string") {
    throw new Error("Missing preference value.");
  }

  if (user) {
    await updateUser({
      preferences: { ...user.preferences, [key]: preferenceValue },
    });

    return { response: { success: true } };
  } else {
    const { preferences } =
      (await anonUserPreferences.parse(request.headers.get("Cookie"))) ||
      defaultPreferences;

    return {
      response: { success: true },
      headers: {
        "Set-Cookie": await anonUserPreferences.serialize({
          preferences: { ...preferences, [key]: preferenceValue },
        }),
      },
    };
  }
}

// This in Remix is how a server request is managed
export async function action({ request }: ActionArgs) {
  const maybeUser = getOptionalUser(request);

  try {
    const { response, headers } = await updateUserPreference(
      request,
      "preferenceKey",
      maybeUser
    );

    return json(response, { headers });
  } catch (e) {
    // Because redirects work by throwing a Response, you need to check if the
    // caught error is a response and return it or throw it again
    if (e instanceof Response) return e;
    if (e instanceof Error) {
      throw json({ message: e.message });
    }

    throw json({ message: "Unknown server error." });
  }
}

Fetching user preferences

We will be building on the example above so we will not duplicate some of the boilerplate like cookie setup, and default preferences.

/** 
 * Isomorphic function that fetches data from the correct location based on the
 * existence of a user.
 *
 * We return an object with preferences and headers since the call site will need
 * to commit the cookie in the Response.
**/
async function fetchUserPreference(request: Request, user?: User) {
  if (user) {
    return { preferences: user.preferences };
  } else {
    const { preferences } =
      (await anonUserPreferences.parse(request.headers.get("Cookie"))) ||
      defaultPreferences;

    return {
      response: { preferences },
      headers: {
        "Set-Cookie": await anonUserPreferences.serialize({ preferences }),
      },
    };
  }
}

// This is the server portion of loading a page in Remix
export async function loader({ request }: LoaderArgs) {
  const maybeUser = getOptionalUser(request);

  try {
    const { preferences, headers } = await fetchUserPreference(
      request,
      maybeUser
    );

    // Do what ever you want with the preferences

    // I'm passing null here, but in a real world use case you would
    // probably have some data you want to pass along on page load
    return json(null, { headers });
  } catch (e) {
    // Because redirects work by throwing a Response, you need to check if the
    // caught error is a response and return it or throw it again
    if (e instanceof Response) return e;
    if (e instanceof Error) {
      throw json({ message: e.message });
    }

    throw json({ message: "Unknown server error." });
  }
}

In a follow up article this week I am going to dive into how you can manage your preference objects to be type-safe using Zod, and backwards compatible by versioning your JSON like it is a little API.

Updates and More

Get updated when new articles, products, or components are released. Also, whatever else I feel like would be fun to send out.