Adam Dawkins

📔 📄

An Introduction to Hyperapp 2 - Part 3

And... Action

In Part 1, we introduced the basics of Hyperapp 2, and in Part 2, we did a rough sketch of the game of Hangman in code. But we're not going to win any awards just yet - the user can't actually do anything.

To handle interactions with the user, or any other form of event, Hyperapp gives us Actions.

Let's quickly check our spec again:

  • The Computer picks a random word for us to guess - hard-coded for now
  • The Player inputs letters to guess the word
  • Like the paper version, correct letters get inserted into the word, incorrect letters get listed elsewhere
  • 8 incorrect guesses and the Player loses - done
  • If the Player fills in the word correctly, they win. - done

Before anything else, we're going to want to use the Object rest/spread syntax in Javascript quite a bit with Hyperapp, and we need to add that to our build so parcel can use it.

'Object rest/spread' syntax allows us to want all of the existing properties of an object, and then override the ones we want to change. It reads nicely, but it's also an important way of doing things - whenever we change the state in Hyperapp we actually need to create a new state object, and object spread does just that.

Here's a quick example:

const cat = {
  name: 'Larry',
  legs: 4,
  sound: 'meow',
};

const dog = {
  ...cat, // <-- this is the spread we want to support
  sound: 'woof',
};

console.log(dog); // => { name: 'Larry', legs: 4, sounds: 'woof' }

Here our dog kept the name and legs properties of cat, but had it's own sound. We'll use this syntax when we want to return a new version of our state. Let's get it setup.

yarn add babel-plugin-transform-object-rest-spread -d

Put the following in a file called .babelrc:

{
  "plugins": ["transform-object-rest-spread"]
}

Now that's out of the way, we'll start by building a form for our user to enter letters. I've included some basic styling on the input.

import {
  div,
  h1,
  h2,
  ul,
  li,
  span,
  input,
  label,
  form,
  button,
} from '@hyperapp/html';

// ...

// VIEWS
// ...

const UserInput = () =>
  form({}, [
    label({for: 'letter'}, 'Your guess:'),
    ,
    input({
      type: 'text',
      id: 'letter',
      maxlength: 1,
      style: {
        border: '2px solid black',
        fontSize: '36px',
        width: '1.5em',
        margin: '0 1em',
        textAlign: 'center',
      },
    }),
    button({type: 'submit'}, 'Guess!'),
  ]);

// THE APP
app({
  init: {
    word: 'application'.split(''),
    guesses: [],
  },
  view: state =>
    div(
      {},
      isGameOver(state)
        ? h1({}, `Game Over! The word was "${state.word.join('')}"`)
        : isVictorious(state)
        ? [h1({}, 'You Won!'), Word(state)]
        : [UserInput(), Word(state), BadGuesses(state)],
    ),
  node: document.getElementById('app'),
});

Nothing happens... Let's change that with an action.

Actions take the current state, an optional argument and return a new state.

For now, we just want to get our action working when we submit the form, so we'll hardcode the letter 'z' into the guess.

// ACTIONS

const GuessLetter = state => ({
  ...state,
  guesses: state.guesses.concat(['z']),
});

NB: We use concat here instead of push because Hyperapp always wants a new state object, not a change to the existing one. To put it formally, state in Hyperapp is immutable.

When the GuessLetter action is called, we return the current state, with the letter 'z' added to the guesses.

We want to call this when the user submits the form, or on the submit event.

form({ onSubmit: GuessLetter } // ...

This is the gist of it, but it won't actually work yet, because by default, submit events change the URL and refresh the page. We need to stop the default behaviour. We can do that manually, by calling event.preventDefault().

  form(
    {
      onSubmit: (state, event) => {
        event.preventDefault();
        return GuessLetter;
      },
    },

This works, but it introduces a lot of extra boilerplate code all over our view. After all, Javascript UIs are all about events, or we'd just be building in plain HTML. Hyperapp has a @hyperapp/events package that has some useful helper functions for this sort of thing.

Introducing Events

Let's install the package:

yarn add @hyperapp/events

And we'll use the preventDefault helper function from there to stop our form refreshing the page.

import {preventDefault} from '@hyperapp/events';

// ...

// VIEWS

const UserInput = letter =>
  form(
    {onSubmit: preventDefault(GuessLetter)},
    // ...
  );

Now we can repeatedly guess the letter 'z' when we submit the form. Let's take it where we need to go, and capture the user input.

Capturing User Input

A key concept in Hyperapp is that there's only one state, and changing the state refreshes our 'loop' around the application. As such, we need to store the user's guessed letter before we submit the form so that we know which letter they've guessed within our GuessLetter action.

This is where we want our GuessLetter action to go:

const GuessLetter = state => ({
  ...state,
  guesses: state.guesses.concat([state.guessedLetter]),
  guessedLetter: '', // reset the letter after the user has guessed it
});

So, let's add a guessedLetter to our state, set the input to be the same value as it, and change it whenever the value of the input changes.

  1. Add the guessedLetter to our initial state.
//  THE APP
app({
  init: {
    word: 'application'.split(''),
    guesses: [],
    guessedLetter: '',
  },
  // ...
});
  1. Pass the letter to our UserInput view, and set it as the value of the input so that we can display it:
// VIEWS

const UserInput = letter =>
  form({onSubmit: preventDefault(GuessLetter)}, [
    label({for: 'letter'}, 'Your guess:'),
    ,
    input({
      value: letter,
      // ...
      },
    }),
    button({type: 'submit'}, 'Guess!'),
  ]);

// THE APP
app({
// ...
view: // ...

     [UserInput(state.guessedLetter), Word(state), BadGuesses(state)],
  // ...
});

  1. Change state.guessedLetter when the input changes.

The onInput event we have takes two arguments, the current state, passed in automatically from Hyperapp, and the event that was triggered, so we can use that to to do this action in line:

input({
  value: letter,
  onInput: (state, event) => ({...state, guessedLetter: event.target.value}),
  // ...
  },
});

And, just like that, we can now make guesses with an input. We have Hangman.

Getting Better

There's still more work to be done though, we need to make the word random, and we can tidy up some of the user experience. We'll look at those in the next part.

Before you go, let's tidy this up a bit.

  1. We'll take the styling out into a stylesheet: style.css
.input {
  border: 2px solid black;
  font-size: 36px;
  width: 1.5em;
  margin: 0 1em;
  text-align: center;
}
<!-- ... -->
<head>
  <link rel="stylesheet" href="./style.css">
</head>
<!-- ... -->
// VIEWS

const UserInput = letter =>
  form({/* ... */}, [
  // ...
    input({
    // ...
      class: 'input', // add the class 'input'
      // remove the style: block
      // ...
    }),
    // ...
  ]);
  1. Get rid of the inline action.
// ACTIONS

const SetGuessedLetter = (state, letter) => ({
  ...state,
  guessedLetter: letter,
});
// VIEWS

input({
  // ...
  onInput: (_, event) => [SetGuessedLetter, event.target.value],
});

This is better, but another helper from @hyperapp/events allows us to abstract this pattern of using the event target value

import {preventDefault, targetValue} from '@hyperapp/events';

// VIEWS
input({
  // ...
  onInput: [SetGuessedLetter, targetValue],
});

So there we are, 101 lines of code, and we have a working Hangman. Let's make it better by introduction random words - in Part 4 (coming soon).