DEV Community

Ilia Mikhailov
Ilia Mikhailov

Posted on • Originally published at codechips.me on

Firebase authentication with XState and Svelte

This is a follow-up to my previous article on Firebase authentication. I was not satisfied with the binary solution and decided to enhance it with XState.

It's hard to understand XState just with light bulb and red light examples. I need something concrete, something to chew on. A practical use case. That's why I asked myself if I could use XState for Firebase authentication.

XState is hard to learn. In order to understand the concepts you must actually sit down and type out the code.

Take 20 minutes of your busy schedule. It will be time well spent. I promise!

What you will learn

Below is a list of the things you will learn:

  • Creating XState machines
  • Using XState in Svelte
  • Encapsulating external libraries in XState
  • Using Firebase authentication in XState

This is what the final result will look like.

firebase authentication xstate login form

Onward.

Requirements

Since this is a follow-up article to Firebase authentication with Svelte we will base all the code on it. Read it first if you want to understand how Firebase authentication works.

What is XState?

XState is a small (and lightweight) library that helps you manage state in your application. The gist of XState is that you application can only be in one state at a time and from that state you can only move to other predefined states by the rules you define. Because of that, it makes working with state very predictable, but notice I didn't say easy.

Even though, XState has one of the best documentations I have seen, it's not easy to understand all its concepts. You have to start with a blank state of mind and take your time to absorb all the new ideas it offers.

Many people think that XState can only be used in UI when working with forms, but that not true. You can use it anywhere you need predicable state.

State machines briefly explained

I won't explain the XState in-depth, because XState's documentation does it 1000 times better that I ever could, but I want to briefly explain state machines.

State machines are the core of XState and you will be writing many when working with the XState library.

Here is a very basic and oversimplified example of an auth state machine just to get an idea on how to work with XState.

import { createMachine, interpret } from 'xstate';

// create a state machine config
const config = {
  id: 'auth',
  initial: 'signedOut',
  states: {
    authenticating: {
      on: {
        'SUCCESS': 'signedIn',
        'FAILURE': 'signedOut'
      }
    },
    signedIn: {
      on: { 'LOGOUT': 'signedOut' }
    },
    signedOut: {
      on: { 'LOGIN': 'authenticating' }
    }
  }
};

// create a machine instance from config
const machine = createMachine(config);

// create an instance of an interpreted machine,
// also known as a service.
const service = interpret(machine)

// start our service
service.start();

service.state.value === 'signedOut';
// true

// send an event that initiates a state transition
// to the 'authenticating' state
service.send('LOGIN');

service.state.matches('authenticating');
// true

service.send('SUCCESS');

service.state.matches('signedIn');
// true

service.stop();
Enter fullscreen mode Exit fullscreen mode

You can do many fancy things with state machines, as you will learn. The basics of the state machines - you make decisions on the current state by sending events to the instance of the state machine.

Identifying all possible states

With our newly gained basic state machine knowledge, let's try to identify the states we have when it comes to our Firebase authentication.

We have three obvious states:

  • Authenticating, when checking user's authentication status
  • Signed out, when the user is logged out
  • Signed in, when the user is logged in

But if we think hard about it we have two more states:

  • Signing in, when the user is logging in
  • Signing out, when the user is logging out

You might think they are actions, but they are actually states. At least in my eyes.

When I am trying to identify states I often start with an action, such as "log in", and try to convert action to state - "logging in". If it makes sense, then it's probably a state.

We can also have additional states that are related to authentication such as loading, where we enrich the user with additional data like roles and permissions.

With our naïve assumptions let's try and build an initial version of our state machine.

Initial auth machine version

Let's get cranking! Create an authMachine.js file in the src/auth directory.

const config = {
  id: 'auth',
  // we want to start by checking if
  // user is logged in when page loads
  initial: 'authenticating',
  // context is where you keep your app state
  context: {
    auth: null,
    error: null
  },
  // all possible authentication states
  states: {
    authenticating: {},
    // we will enrich the user profile
    // with additional data
    loading: {},
    signedIn: {
      // when receiving 'LOGOUT' event
      // transition to singingOut state
      on: { LOGOUT: { target: 'signingOut' } }
    },
    signedOut: {
      // when receiving 'LOGIN' event
      // transition to signingIn state
      on: { LOGIN: { target: 'signingIn' } }
    },
    signingIn: {},
    signingOut: {}
  }
};

Enter fullscreen mode Exit fullscreen mode

That will do for now. But how do we check if the user is authenticated or not? That's where XState's effects come in.

Doing side effects in XState

In XState you can invoke different actions when you transition to a state. You can invoke callbacks, promises, observables and even other state machines.

The options are many, but in our example we will use promises to keep things simple. Because everyone knows how promises work, right?

When we want transition to a different state (by sending an event for example) we can invoke a XState service, a function that return promise in our case, with the invoke property.

When invoking the service XState will pass two arguments to it - machine's context and event that triggered the state transition.

Here is a pseudo-example of it could look for our authenticating state.

states: {
  authenticating: {
    // when entering a state invoke
    // the service
    invoke: {
      id: 'checkAuth',
      // can be inline or we can refer to it
      // by an identifier as we will see later
      src: (ctx, event) => {
        console.log({ctx, event});
        return new Promise((resolve, reject) => {
          // simulate a slow auth method
          setTimeout(resolve, 1000);
        });
      },
      // called on Promise.resolve()
      onDone: {
        target: 'signedIn'
      },
      // called on Promise.reject()
      onError: {
        target: 'signedOut',
      }
    }
  },
  // ...
  signedIn: {},
  signedOut: {}
}
Enter fullscreen mode Exit fullscreen mode

Are you with me so far? In the example above when our auth machine starts, it will start in the authenticating state and immediately invoke the promise with the checkAuth id.

The promise will in this case resolve and therefore call the onDone hook, which will transition from authenticating to signedIn state. In the onDone hook it will pass in the current app context and event as arguments.

One important concept to understand is that XState uses events to transition from one state to another. They are external and also internal stimulus that we can act on.

If you ever worked with Redux you can think of events as Redux actions. Every event always has a type, its name, and along side with that you can pass in data.

As an analogy we can think of (context, event) arguments as Redux (state, action) concept.

Don't know if its a fair comparison, but that's how I see it. More on that later.

Wiring up the machine

To make things more clear let's play around a bit our auth machine in its current state and wire it up to few buttons that we can push.

First, we need to wrap it in a Svelte readable store. Create a useMachine.js file in src/auth directory.

// useMachine.js

import { readable } from 'svelte/store';
import { interpret } from 'xstate';

export const useMachine = (config, options) => {
  // interpret the machine config
  const service = interpret(config, options);

  // wrap machine in a svelte readable store with
  // initial state (defined in config) as its starting state
  const store = readable(service.initialState, set => {
    // every time we change state onTransition
    // hook is triggered
    service.onTransition(state => {
      set(state);
    });

    // start the machine
    service.start();

    return () => {
      service.stop();
    };
  });

  // return a custom Svelte store
  return {
    state: store,
    send: service.send
  };
};
Enter fullscreen mode Exit fullscreen mode

Before we can use machine we need to create it. Adjust the authMachine.js to following.

// authMachine.js

import { createMachine } from 'xstate';

const config = {
  // ...
}

export const initAuthMachine = () => createMachine(config);
Enter fullscreen mode Exit fullscreen mode

Now we are ready to use it in the our auth/index.js file. Let's change it to this for now.

import firebase from 'firebase/app';
import 'firebase/auth';
import { useMachine } from './useMachine';
import { initAuthMachine } from './authMachine';

const userMapper = claims => ({
  id: claims.user_id,
  name: claims.name,
  email: claims.email,
  picture: claims.picture
});

export const initAuth = (useRedirect = false) => {
  const auth = firebase.auth();

  const loginWithEmailPassword = (email, password) =>
    auth.signInWithEmailAndPassword(email, password);

  const loginWithGoogle = () => {
    const provider = new firebase.auth.GoogleAuthProvider();

        return useRedirect
            ? auth.signInWithRedirect(provider)
            : auth.signInWithPopup(provider);
  };

  const authMachine = initAuthMachine();

  return useMachine(authMachine);
};
Enter fullscreen mode Exit fullscreen mode

There are a few unused functions, but don't worry, we will put them to use later.

We can finally wire up the machine in our App.svelte file. You can find the complete setup code for the app and styles for the main file in my previous tutorial on Firebase authentication.

<!-- App.svelte with styles omitted -->

<script>
  import firebase from 'firebase/app';
  import { initAuth } from './auth';
  import { fade } from 'svelte/transition';

  const firebaseConfig = {
    // vite reads env variables from .env file in root dir
    apiKey: `${import.meta.env.VITE_FIREBASE_KEY}`,
    authDomain: 'testing-firebase-emulators.firebaseapp.com',
    projectId: 'testing-firebase-emulators'
  };

  firebase.initializeApp(firebaseConfig);

  // user custom auth machine store
  const { state, send } = initAuth();

  const loginHandler = async event => {
    const { email, password } = event.target.elements;
    // send login event
    send('LOGIN');
  };
</script>

<div class="wrapper">
  <div class="w-full max-w-xs">

    {#if $state.matches('authenticating')}
      <h2 class="text-2xl text-center">Authenticating ...</h2>
    {/if}

    {#if $state.matches('signedIn')}
      <div class="text-center">
        <button type="button" class="mt-3" on:click={() => send('LOGOUT')}>
          Logout
        </button>
      </div>
    {/if}

    {#if $state.matches('signedOut')}
      <form
        on:submit|preventDefault={loginHandler}
        class="px-8 pt-6 pb-8 bg-white shadow-md"
      >
        <div class="mb-4">
          <label for="email">Email</label>
          <input
            class="input-field"
            id="email"
            type="email"
            placeholder="name@acme.com"
          />
        </div>
        <div class="mb-6">
          <label for="password">Password</label>
          <input
            class="input-field"
            id="password"
            type="password"
            placeholder="******************"
          />
        </div>
        <div>
          <button type="submit">Sign In</button>
        </div>
        <div class="mt-3">
          <button type="button" on:click|preventDefault={() => send('LOGIN')}>
            Sign In with Google
          </button>
        </div>
      </form>
    {/if}

    <!-- debugging: show current state -->
    <div class="p-3 mt-5 bg-blue-200">
      <pre>{JSON.stringify($state.value, null, 2)}</pre>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

You might have noticed that we only have "if" statements in our code. By using XState you can only be in one state at a time. Pretty neat!

Manipulating app state using XState actions

We've come far, but without app state our app is useless. Let's do something about it.

Remember that we defined a context property in our auth machine config? That's where we are going to keep our app state.

For manipulating it, XState has the concept of actions, fire-and-forget side effects.

XState actions are functions that take context and event as arguments. Actions can do many useful things, but you will most likely use actions to manipulate the XState context.

To understand the concept we will continue with a pseudo-example of transitioning from authenticating state, but instead we will reject the promise and put that error on the context.error property.

import { assign } from 'xstate';

// ...

states: {
  authenticating: {
    invoke: {
      id: 'checkAuth',
      src: (ctx, event) => {
        console.log({ ctx, event });
        return new Promise((resolve, reject) => {
          // simulate a slow auth method
          setTimeout(() => reject('wrong password'), 1000);
        });
      },
      // called on Promise.resolve()
      onDone: {
        target: 'signedIn'
      },
      // called on Promise.reject()
      onError: {
        target: 'signedOut',
        // assign the error to the `context.error`
        // that comes in event.data from the Promise.reject.
        // assign is a helper function that you import from XState
        // that we will use later
        actions: assign({
          error: (_, event) => event.data
        })
      }
    }
  },
  // ...
  signedIn: {},
  signedOut: {}
}
Enter fullscreen mode Exit fullscreen mode

XState is flexible when it comes to defining its config. You can supply a list of actions and also pass them in a separate external dictionary and refer to them by key name instead of writing them inline.

Actions are kind of hard to understand, but stick with me. Everything will clear as we progress further.

Defining side effects as XState services

Now that we know how to perform side effects in XState we need to wrap all Firebase authentication methods in XState services and also add actions to manipulate our app state.

Remember that I told that we can supply both actions and services as external objects and refer to them by name in our config? Let's do that.

We will start with XState services. Open the auth/index.js file and replace initAuth function with this code.

// auth/index.js

// constructor function. need to call it after we
// initialize our firebase app
export const initAuth = (useRedirect = false) => {
    const auth = firebase.auth();

    const loginWithEmailPassword = (email, password) =>
        auth.signInWithEmailAndPassword(email, password);

    const loginWithGoogle = () => {
        const provider = new firebase.auth.GoogleAuthProvider();

        return useRedirect
            ? auth.signInWithRedirect(provider)
            : auth.signInWithPopup(provider);
    };

  // define XState services
  const services = {
    authChecker: () =>
      // wrap the onAuthStateChanged hook in a promise and
      // immediately unsubscribe when triggered
      new Promise((resolve, reject) => {
        const unsubscribe = firebase.auth().onAuthStateChanged(auth => {
          unsubscribe();
          return auth ? resolve(auth) : reject();
        });
      }),
    authenticator: (_, event) => {
      if (event.provider === 'email') {
        return loginWithEmailPassword(event.email, event.password);
      } else if (event.provider === 'google') {
        return loginWithGoogle();
      }
    },
    loader: (ctx, _) => {
      return new Promise(resolve => {
        setTimeout(() => {
          ctx.auth
            .getIdTokenResult()
            .then(({ claims }) => userMapper(claims))
            .then(resolve);
        }, 1500);
      });
    },
    logout: () => auth.signOut()
  };

  // pass the services as argument
    const authMachine = initAuthMachine(services);

    return useMachine(authMachine);
};
Enter fullscreen mode Exit fullscreen mode

It's time to fix our auth machine config.

Fixing the auth machine config

I can talk a lot about how to adjust our config, but code speaks louder than words, even if it's made up of words.

Here is updated authMachine.js in all its glory, with comments and all.

// authMachine.js

import { createMachine, assign } from 'xstate';

const config = {
  id: 'auth',
  // we want to start by checking if
  // user is logged in when page loads
  initial: 'authenticating',
  // context is where you keep state
  context: {
    auth: null,
    user: null,
    error: null
  },
  // all possible authentication states
  states: {
    authenticating: {
      // when entering a state invoke
      // the authChecker service
      invoke: {
        id: 'authChecker',
        src: 'authChecker',
        onDone: { target: 'loading', actions: 'setAuth' },
        onError: { target: 'signedOut' }
      }
    },
    // we will enrich the user profile
    // with additional data
    loading: {
      invoke: {
        id: 'loader',
        src: 'loader',
        onDone: { target: 'signedIn', actions: 'setUser' },
        onError: {
          target: 'signedOut.failure',
          actions: ['setError', 'clearAuth']
        }
      }
    },
    signedIn: {
      // when receiving 'LOGOUT' event
      // transition to singingOut state
      on: { LOGOUT: { target: 'signingOut' } }
    },
    // signedOut has two sub-states
    // we will transition to failure in
    // case of wrong password, username
    // or network error
    signedOut: {
      initial: 'ok',
      states: {
        ok: { type: 'final' },
        failure: {}
      },
      on: {
        LOGIN: { target: 'signingIn' }
      }
    },
    signingIn: {
      invoke: {
        id: 'authenticator',
        src: 'authenticator',
        onDone: {
          target: 'authenticating',
          // clear error if successful login
          actions: 'clearError'
        },
        onError: {
          // transition to failure state
          // and set an error
          target: 'signedOut.failure',
          actions: 'setError'
        }
      }
    },
    signingOut: {
      invoke: {
        id: 'logout',
        src: 'logout',
        onDone: {
          target: 'signedOut',
          actions: ['clearAuth', 'clearError']
        },
        onError: {
          target: 'signedOut.failure',
          actions: ['clearAuth', 'setError']
        }
      }
    }
  }
};

export const initAuthMachine = services => {
  // define XState actions so that we can
  // refer to them by name in machine config
  const actions = {
    // clear user info on logout
    clearAuth: assign({ user: null, auth: null }),
    clearError: assign({ error: null }),
    // put Firebase auth object on context
    setAuth: assign({ auth: (_, event) => event.data }),
    // put user on context in loading service
    setUser: assign({ user: (_, event) => event.data }),
    setError: assign({
      error: (_, event) => event.data
    })
  };

  // create an options object containing
  // actions and services
  const options = {
    actions,
    services
  };

  return createMachine(config, options);
};
Enter fullscreen mode Exit fullscreen mode

And this is what it looks like in the XState's visualizer.

XState visualizer Firebase authentication machine

If you start at the authenticating state, and follow a path, it will all make sense. Notice that in configuration we refer to our services and actions by their names (keys) instead of writing them inline.

One thing to know is that if you have a transition with no action you can omit the target keyword and just use a string. XState is flexible, but in my opinion, it's good to be consistent and not take any shortcuts.

You can also see that we supplied an options object with our actions and services to createMachine functions. XState has plenty of different options to create your machines.

Understanding XState events

Just like in Redux, you define an action, but it's called event in this case.

In the event you can also supply event data. Here is the link to the XState documentation that explains it in great detail.

Below are a few examples of XState events.

// simplest case possible
send('LOGIN');

// same as above, this is what XState tranforms to internally
send({ type: 'LOGIN'});

// provide an object payload
send('LOGIN', { provider: 'google'});

// or
send({type:'LOGIN', provider: 'google'});

qend('LOGIN', { provider: 'email', email: 'john@example.com', password: 'qwerty' });
Enter fullscreen mode Exit fullscreen mode

It's crucial that you understand the concept of XState events, because they are the base of everything in XState.

Using auth machine states in UI

Empowered by our new knowledge, let's do a final adjustment to our UI in App.svelte. We already logic for state management in place. We only need to adjust our handlers with correct events and also take care of error handling.

Here is the final version of App.svelte sprinkled with comments.

<!-- App.svelte -->

<style lang="postcss">
    label {
        @apply block mb-2 text-sm font-bold text-gray-700;
    }
    .input-field {
        @apply border w-full py-2 px-3 text-gray-700 mb-3;
    }
    .input-field:focus {
        @apply shadow-outline outline-none;
    }
    button {
        @apply w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-sm;
    }
    button:hover {
        @apply bg-blue-700;
    }
    button:focus {
        @apply outline-none shadow-outline;
    }
    .wrapper {
        @apply flex flex-grow h-screen justify-center items-center bg-blue-100;
    }
</style>

<script>
    import firebase from 'firebase/app';
    import { initAuth } from './auth';
    import { fade } from 'svelte/transition';

    const firebaseConfig = {
        // vite reads env variables from .env file in root dir
        apiKey: `${import.meta.env.VITE_FIREBASE_KEY}`,
        authDomain: 'testing-firebase-emulators.firebaseapp.com',
        projectId: 'testing-firebase-emulators'
    };

    firebase.initializeApp(firebaseConfig);

    // use custom auth machine store
    const { state, send } = initAuth();

    const loginHandler = async event => {
        const { email, password } = event.target.elements;
        // send login event
        send('LOGIN', {
            provider: 'email',
            email: email.value,
            password: password.value
        });
    };

    // we don't want to be explicit about signingIn state
    $: displayLoginForm = ['signingIn', 'signedOut'].some($state.matches);
</script>

<div class="wrapper">
    <div class="w-full max-w-xs">
        {#if $state.matches('authenticating')}
            <h2 class="text-2xl text-center">Authenticating ...</h2>
        {/if}

        {#if $state.matches('loading')}
            <h2 class="text-2xl text-center">Loading ...</h2>
        {/if}

        <!-- uncomment if you want to be explicit about signingIn state -->
        <!--
        {#if $state.matches('signingIn')}
            <h2 class="text-2xl text-center">Signing in ...</h2>
        {/if}
        -->

        {#if $state.matches('signedIn')}
            <div class="text-center">
                <h2 class="text-2xl">{$state.context.user.email}</h2>
                <button type="button" class="mt-3" on:click={() => send('LOGOUT')}>
                    Logout
                </button>
            </div>
        {/if}

        {#if displayLoginForm}
            <form
                on:submit|preventDefault={loginHandler}
                class="px-8 pt-6 pb-8 bg-white shadow-md"
            >
                <div class="mb-4">
                    <label for="email">Email</label>
                    <input
                        class="input-field"
                        id="email"
                        type="email"
                        placeholder="name@acme.com"
                    />
                </div>
                <div class="mb-6">
                    <label for="password">Password</label>
                    <input
                        class="input-field"
                        id="password"
                        type="password"
                        placeholder="******************"
                    />
                </div>
                <!-- show auth errors -->
                {#if $state.context.error}
                    <div class="bg-red-500 p-3 mb-6" transition:fade>
                        {$state.context.error}
                    </div>
                {/if}
                <div>
                    <button type="submit">Sign In</button>
                </div>
                <div class="mt-3">
                    <!-- tell auth machine that this is a Google login -->
                    <button
                        type="button"
                        on:click|preventDefault={() => send('LOGIN', {
                                provider: 'google'
                            })}
                    >
                        Sign In with Google
                    </button>
                </div>
            </form>
        {/if}

    </div>
</div>

Enter fullscreen mode Exit fullscreen mode

If you look carefully you will see that we added data to our events and we also show an error to the user in case of authentication failure.

Notice that we communicate with our machine by sending events. The logic is encapsulated in the machine itself.

I don't know about you, but I actually find it quite beautiful.

While our states are now explicit, I am struggling to show user correct information (or state transitions).

But this is not a code problem, only my lack of UX skills.

To compensate for it I created a reactive variable displayLoginForm. That way login form is visible even in the signingIn state.

Conclusion

This was a fun and educating experience. We achieved our goal and have a fully functioning Firebase authentication solution.

There are many ways to structure your application state with XState and if you are not careful you will end up with an unmanageable config that is hard to understand. The key is to write small, single purpose machines.

XState is also flexible in how you define your state machines. Its flexibility is also one of its weaknesses, because it's easy to get lost in all the options available and what to use where. It's easy to get into an analysis paralysis state, especially when you are just starting out learning the library.

With that said, I am happy with results and look forward to exploring XState further. Hope you do too now!

Here is the code to the final solution on Github.

GitHub logo codechips / svelte-firebase-auth-xstate-example

Example of how to use XState and Svelte with Firebase authentication

Thanks for reading!

Top comments (2)

Collapse
 
azell profile image
Adam Zell

Have you had a chance to look at thisrobot.life/ and compare it with XState?

Collapse
 
codechips profile image
Ilia Mikhailov

I've looked at it some time ago, but not in-depth. Thanks for reminding me! Will take a closer look soon.