Making dark theme switcher with PostCSS.

You have noticed that there is a new design trend that is floating around web design since 2019, the dark mode. Facebook, Apple, and Google both introduced the dark version of their software.

Why a dark theme

Most of you probably think this is just a trend that will disappear after some years, well, let me say that this is not like many other trends, dark UI provide different advantages and they are not something just related to the “designer mood”. Let’s see why a dark mode on your applications and websites are something useful.

Better for batteries

Pixels on a screen consume more energy to display light colors rather than dark ones. Consequently, devices’ batteries can save energy and improve their daily duration while using dark UI.

Better for dark environments

Most of us use their smartphone and laptops while at home. Such environments are typically not so bright. The dark mode can help the use of the application while indoor, without causing visual disturbances.

Better for people

Some people with — or without — visual diseases, like epilepsy, can have unfortunate events by being flashed by bright applications. Having a dark mode means being more accessible.

Preparing styles

A very simple theme switcher should offer at least 3 options:

  • Dark theme
  • Light theme
  • Automatic theme (should be on by default)

Wait, what’s the automatic theme? Well, modern operating systems allow users to change the global visual appearance by setting os-wide options that enable the dark or light mode. The automatic option make sure to respect the OS preference if the user has not specified any theme.

To make this even more simple, we’ll use PostCSS and a simple but useful plugin called postcss-dark-theme-class.

yarn add postcss-dark-theme-class

This plugin will do 70% of the work, once installed, add it to your PostCSS config and configure the selectors you want to use to activate the correct theme, which will be used by the plugin to generate the correct CSS:

module.exports = {
  plugins: [
    /* ...other plugins */

    require('postcss-dark-theme-class')({
      darkSelector: '[data-theme="dark"]',
      lightSelector: '[data-theme="light"]'
    })
  ]
}

Once the plugin is up and running, we can start defining our dark and light themes using a CSS specific media query prefers-color-scheme. This special media query will handle the automatic part of our themes by applying the correct theme based on the user’s OS preferences:

:root {
  --accent-color: hsl(226deg 100% 50%);
  --global-background: hsl(0 0% 100%);
  --global-foreground: hsl(0 0% 0%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --accent-color: hsl(226deg 100% 50%);
    --global-background: hsl(0 0% 0%);
    --global-foreground: hsl(0 0% 100%);
  }
}

If the user is using a dark version of his OS, the set inside the media query will apply, overwriting others, otherwhise the set of properties outside the media query is used. Since it’s pure CSS, this behaviour is on by default.

Browsers will now adapt the color scheme automatically based on the users’ OS preferences. Nice done! 🚀 Now it’s time to make the theme switcher allow users to specify what theme to use, overriding the OS preference.

Preview(opens in a new tab)

Making the theme switcher

s we said, our switcher should have three options, we can use a simple select element, or build a set of buttons:

<button type="button" data-set-theme="auto">Auto</button>
<button type="button" data-set-theme="dark">Dark</button>
<button type="button" data-set-theme="light">Light</button>

We’ll build the switcher using vanilla JS, but you can do it with any framework you want, the concept is the same: we have to add the selectors we defined inside the PostCSS plugin to the root element, based on the clicked button.

const html = document.documentElement
const themeButtons = document.querySelectorAll('[data-set-theme]');

themeButtons.forEach((button) => {
  const theme = button.dataset.setTheme;

  button.addEventListener('click', () => {
    html.dataset.theme = theme;
  })
})
This is a very basic and ugly JS example

Each time we click on a theme button, the value set as data-set-theme is applied as value of the data-theme attribute on the docEach time we click on a theme button, the value set as `data-set-theme` is applied as value of the `data-theme` attribute on the document root element, we also change the [aria-current] attribute.

Check it live:



Where is the magic?

The magic is made by postcss-dark-theme-class — which will add our [data-theme] custom attribute to the :root selectors we wrote — during the CSS transpilation. Here what it generates from our code:

/* Our automatic and user specified light theme */
:root {
  --accent-color: hsl(226deg, 100%, 50%);
  --global-background: hsl(0, 0%, 100%);
  --global-foreground: hsl(0, 0%, 0%);
}

/* Our automatic dark theme */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --accent-color: hsl(226deg, 100%, 50%);
    --global-background: hsl(0, 0%, 0%);
    --global-foreground: hsl(0, 0%, 100%);
  }
}

/* Our dark theme specified by the user */
:root[data-theme="dark"] {
  --accent-color: hsl(226deg, 100%, 50%);
  --global-background: hsl(0, 0%, 0%);
  --global-foreground: hsl(0, 0%, 100%);
}

Bonus tip

You may notice that the --accent-color custom property defined inside themes doesn’t change. If you have colors that will not change based on the theme, you can remove them from the prefers-color-scheme at-rule.

In this way, they will not be duplicated and the one defined outside the media query will always apply.

:root {
  --accent-color: hsl(226deg 100% 50%);
  --global-background: hsl(0 0% 100%);
  --global-foreground: hsl(0 0% 0%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --global-background: hsl(0 0% 0%);
    --global-foreground: hsl(0 0% 100%);
  }
}

The post Making dark theme switcher with PostCSS. appeared first on CSS-Tricks.

Model-Based Testing in React with State Machines

Testing applications is crucially important to ensuring that the code is error-free and the logic requirements are met. However, writing tests manually is tedious and prone to human bias and error. Furthermore, maintenance can be a nightmare, especially when features are added or business logic is changed. We’ll learn how model-based testing can eliminate the need to manually write integration and end-to-end tests, by automatically generating full tests that keep up-to-date with an abstract model for any app.

From unit tests to integration tests, end-to-end tests, and more, there are many different testing methods that are important in the development of non-trivial software applications. They all share a common goal, but at different levels: ensure that when anyone uses the application, it behaves exactly as expected without any unintended states, bugs, or worse, crashes.

Testing Trophy
Testing Trophy showing importance of different types of tests

Kent C. Dodds describes the practical importance of writing these tests in his article, Write tests. Not too many. Mostly integration. Some tests, like static and unit tests, are easy to author, but don't completely ensure that every unit will work together. Other tests, like integration and end-to-end (E2E) tests, take more time to author, but give you much more confidence that the application will work as the user expects, since they replicate scenarios similar to how a user would use the application in real life.

So why are there never many integration nor E2E in applications nowadays, yet hundreds (if not thousands) of unit tests? The reasons range from not enough resources, not enough time, or not enough understanding of the importance of writing these tests. Furthermore, even if numerous integration/E2E tests are written, if one part of the application changes, most of those long and complicated tests need to be rewritten, and new tests need to be written. Under deadlines, this quickly becomes infeasible.

From Automated to Autogenerated

The status-quo of application testing is:

  1. Manual testing, where no automated tests exist, and app features and user flows are tested manually
  2. Writing automated tests, which are scripted tests that can be executed automatically by a program, instead of being manually tested by a human
  3. Test automation, which is the strategy for executing these automated tests in the development cycle.

Needless to say, test automation saves a lot of time in executing the tests, but the tests still need to be manually written. It would sure be nice to tell some sort of tool: "Here is a description of how the application is supposed to behave. Now generate all the tests, even the edge cases."

Thankfully, this idea already exists (and has been researched for decades), and it's called model-based testing. Here's how it works:

  1. An abstract "model" that describes the behavior of your application (in the form of a directed graph) is created
  2. Test paths are generated from the directed graph
  3. Each "step" in the test path is mapped to a test that can be executed on the application.

Each integration and E2E test is essentially a series of steps that alternate between:

  1. Verify that the application looks correct (a state)
  2. Simulate some action (to produce an event)
  3. Verify that the application looks right after the action (another state)

If you’re familiar with the given-when-then style of behavioral testing, this will look familiar:

  1. Given some initial state (precondition)
  2. When some action occurs (behavior)
  3. Then some new state is expected (postcondition).

A model can describe all the possible states and events, and automatically generate the "paths" needed to get from one state to another, just like Google Maps can generate the possible routes between one location and another. Just like a map route, each path is a collection of steps needed to get from point A to point B.

Integration Testing Without a Model

To better explain this, consider a simple "feedback" application. We can describe it like so:

  • A panel appears asking the user, "How was your experience?"
  • The user can click "Good" or "Bad"
  • When the user clicks "Good," a screen saying "Thanks for your feedback" appears.
  • When the user clicks "Bad," a form appears, asking for further information.
  • The user can optionally fill out the form and submit the feedback.
  • When the form is submitted, the thanks screen appears.
  • The user can click "Close" or press the Escape key to close the feedback app on any screen.

See the Pen
Untitled by David Khourshid(
@davidkpiano)
on CodePen.

Manually Testing the App

The @testing-library/react library makes it straightforward to render React apps in a testing environment with its render() function. This returns useful methods, such as:

  • getByText, which identifies DOM elements by the text contained inside of them
  • baseElement, which represents the root document.documentElement and will be used to trigger a keyDown event
  • queryByText, which will not throw an error if a DOM element containing the specified text is missing (so we can assert that nothing is rendered)
import Feedback from './App';
import { render, fireEvent, cleanup } from 'react-testing-library';

// ...

// Render the feedback app
const {
  getByText,
  getByTitle,
  getByPlaceholderText,
  baseElement,
  queryByText
} = render(<Feedback />);

// ...

More information can be found in the @testing-library/react documentation. Let's write a couple integration tests for this with Jest (or Mocha) and @testing-library/react:

import { render, fireEvent, cleanup } from '@testing-library/react';

describe('feedback app', () => {
  afterEach(cleanup);

  it('should show the thanks screen when "Good" is clicked', () => {
    const { getByText } = render(<Feedback />);

    // The question screen should be visible at first
    assert.ok(getByText('How was your experience?'));

    // Click the "Good" button
    fireEvent.click(getByText('Good'));

    // Now the thanks screen should be visible
    assert.ok(getByText('Thanks for your feedback.'));
  });

  it('should show the form screen when "Bad" is clicked', () => {
    const { getByText } = render(<Feedback />);

    // The question screen should be visible at first
    assert.ok(getByText('How was your experience?'));

    // Click the "Bad" button
    fireEvent.click(getByText('Bad'));

    // Now the form screen should be visible
    assert.ok(getByText('Care to tell us why?'));
  });
});

Not too bad, but you'll notice that there's some repetition going on. At first, this isn't a big deal (tests shouldn't necessarily be DRY), but these tests can become less maintainable when:

  • Application behavior changes, such as adding a new steps or deleting steps
  • User interface elements change, in a way that might not even be a simple component change (such as trading a button for a keyboard shortcut or gesture)
  • Edge cases start occurring and need to be accounted for.

Furthermore, E2E tests will test the exact same behavior (albeit in a more realistic testing environment, such as a live browser with Puppeteer or Selenium), yet they cannot reuse the same tests since the code for executing the tests is incompatible with those environments.

The State Machine as an Abstract Model

Remember the informal description of our feedback app above? We can translate that into a model that represents the different states, events, and transitions between states the app can be in; in other words, a finite state machine. A finite state machine is a representation of:

  • The finite states in the app (e.g., question, form, thanks, closed)
  • An initial state (e.g., question)
  • The events that can occur in the app (e.g., CLICK_GOOD, CLICK_BAD for clicking the good/bad buttons, CLOSE for clicking the close button, and SUBMIT for submitting the form)
  • Transitions, or how one state transitions to another state due to an event (e.g., when in the question state and the CLICK_GOOD action is performed, the user is now in the thanks state)
  • Final states (e.g., closed), if applicable.

The feedback app's behavior can be represented with these states, events, and transitions in a finite state machine, and looks like this:

State diagram of example Feedback app

A visual representation can be generated from a JSON-like description of the state machine, using XState:

import { Machine } from 'xstate';

const feedbackMachine = Machine({
  id: 'feedback',
  initial: 'question',
  states: {
    question: {
      on: {
        CLICK_GOOD: 'thanks',
        CLICK_BAD: 'form',
        CLOSE: 'closed'
      }
    },
    form: {
      on: {
        SUBMIT: 'thanks',
        CLOSE: 'closed'
      }
    },
    thanks: {
      on: {
        CLOSE: 'closed'
      }
    },
    closed: {
      type: 'final'
    }
  }
});

https://xstate.js.org/viz/?gist=e711330f8aad8b52da76419282555820

If you're interested in diving deeper into XState, you can read the XState docs, or read a great article about using XState with React by Jon Bellah. Note that this finite state machine is used only for testing, and not in our actual application — this is an important principle of model-based testing, because it represents how the user expects the app to behave, and not its actual implementation details. The app doesn’t necessarily need to be created with finite state machines in mind (although it’s a very helpful practice).

Creating a Test Model

The app's behavior is now described as a directed graph, where the nodes are states and the edges (or arrows) are events that denote the transitions between states. We can use that state machine (the abstract representation of the behavior) to create a test model. The @xstate/graph library contains a createModel function to do that:

import { Machine } from 'xstate';
import { createModel } from '@xstate/test';

const feedbackMachine = Machine({/* ... */});

const feedbackModel = createModel(feedbackMachine);

This test model is an abstract model which represents the desired behavior of the system under test (SUT) — in this example, our app. With this testing model, test plans can be created which we can use to test that the SUT can reach each state in the model. A test plan describes the test paths that can be taken to reach a target state.

Verifying States

Right now, this model is a bit useless. It can generate test paths (as we’ll see in the next section) but to serve its purpose as a model for testing, we need to add a test for each of the states. The @xstate/test package will read these test functions from meta.test:

const feedbackMachine = Machine({
  id: 'feedback',
  initial: 'question',
  states: {
    question: {
      on: {
        CLICK_GOOD: 'thanks',
        CLICK_BAD: 'form',
        CLOSE: 'closed'
      },
      meta: {
        // getByTestId, etc. will be passed into path.test(...) later.
        test: ({ getByTestId }) => {
          assert.ok(getByTestId('question-screen'));
        }
      }
    },
    // ... etc.
  }
});

Notice that these are the same assertions from the manually written tests we’ve created previously with @testing-library/react. The purpose of these tests is to verify the precondition that the SUT is in the given state before executing an event.

Executing Events

To make our test model complete, we need to make each of the events, such as CLICK_GOOD or CLOSE, “real” and executable. That is, we have to map these events to actual actions that will be executed in the SUT. The execution functions for each of these events are specified in createModel(…).withEvents(…):

import { Machine } from 'xstate';
import { createModel } from '@xstate/test';

const feedbackMachine = Machine({/* ... */});

const feedbackModel = createModel(feedbackMachine)
  .withEvents({
    // getByTestId, etc. will be passed into path.test(...) later.
    CLICK_GOOD: ({ getByText }) => {
      fireEvent.click(getByText('Good'));
    },
    CLICK_BAD: ({ getByText }) => {
      fireEvent.click(getByText('Bad'));
    },
    CLOSE: ({ getByTestId }) => {
      fireEvent.click(getByTestId('close-button'));
    },
    SUBMIT: {
      exec: async ({ getByTestId }, event) => {
        fireEvent.change(getByTestId('response-input'), {
          target: { value: event.value }
        });
        fireEvent.click(getByTestId('submit-button'));
      },
      cases: [{ value: 'something' }, { value: '' }]
    }
  });

Notice that you can either specify each event as an execution function, or (in the case of SUBMIT) as an object with the execution function specified in exec and sample event cases specified in cases.

From Model To Test Paths

Take a look at the visualization again and follow the arrows, starting from the initial question state. You'll notice that there are many possible paths you can take to reach any other state. For example:

  • From the question state, the CLICK_GOOD event transitions to...
  • the form state, and then the SUBMIT event transitions to...
  • the thanks state, and then the CLOSE event transitions to...
  • the closed state.

Since the app's behavior is a directed graph, we can generate all the possible simple paths or shortest paths from the initial state. A simple path is a path where no node is repeated. That is, we're assuming the user isn't going to visit a state more than once (although that might be a valid thing to test for in the future). A shortest path is the shortest of these simple paths.

Rather than explaining algorithms for traversing graphs to find shortest paths (Vaidehi Joshi has great articles on graph traversal if you're interested in that), the test model we created with @xstate/test has a .getSimplePathPlans(…) method that generates test plans.

Each test plan represents a target state and simple paths from the initial state to that target state. Each test path represents a series of steps to get to that target state, with each step including a state (precondition) and an event (action) that is executed after verifying that the app is in the state.

For example, a single test plan can represent reaching the thanks state, and that test plan can have one or more paths for reaching that state, such as question -- CLICK_BAD → form -- SUBMIT → thanks, or question -- CLICK_GOOD → thanks:

testPlans.forEach(plan => {
  describe(plan.description, () => {
    // ...
  });
});

We can then loop over these plans to describe each state. The plan.description is provided by @xstate/test, such as reaches state: "question":

// Get test plans to all states via simple paths
const testPlans = testModel.getSimplePathPlans();

And each path in plan.paths can be tested, also with a provided path.description like via CLICK_GOOD → CLOSE:

testPlans.forEach(plan => {
  describe(plan.description, () => {
    // Do any cleanup work after testing each path
    afterEach(cleanup);

    plan.paths.forEach(path => {
      it(path.description, async () => {
        // Test setup
        const rendered = render(<Feedback />);

        // Test execution
        await path.test(rendered);
      });
    });
  });
});

Testing a path with path.test(…) involves:

  1. Verifying that the app is in some state of a path’s step
  2. Executing the action associated with the event of a path’s step
  3. Repeating 1. and 2. until there are no more steps
  4. Finally, verifying that the app is in the target plan.state.

Finally, we want to ensure that each of the states in our test model were tested. When the tests are run, the test model keeps track of the tested states, and provides a testModel.testCoverage() function which will fail if not all states were covered:

it('coverage', () => {
  testModel.testCoverage();
});

Overall, our test suite looks like this:

import React from 'react';
import Feedback from './App';
import { Machine } from 'xstate';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { assert } from 'chai';
import { createModel } from '@xstate/test';

describe('feedback app', () => {
  const feedbackMachine = Machine({/* ... */});
  const testModel = createModel(feedbackMachine)
    .withEvents({/* ... */});

  const testPlans = testModel.getSimplePathPlans();
  testPlans.forEach(plan => {
    describe(plan.description, () => {
      afterEach(cleanup);
      plan.paths.forEach(path => {
        it(path.description, () => {
          const rendered = render(<Feedback />);
          return path.test(rendered);
        });
      });
    });
  });

  it('coverage', () => {
    testModel.testCoverage();
  });
});

This might seem like a bit of setup, but manually scripted integration tests need to have all of this setup anyway, in a much less abstracted way. One of the major advantages of model-based testing is that you only need to set this up once, whether you have 10 tests or 1,000 tests generated.

Running the Tests

In create-react-app, the tests are ran using Jest via the command npm test (or yarn test). When the tests are ran, assuming they all pass, the output will look something like this:

PASS  src/App.test.js
feedback app
  ✓ coverage
  reaches state: "question" 
    ✓ via  (44ms)
  reaches state: "thanks" 
    ✓ via CLICK_GOOD (17ms)
    ✓ via CLICK_BAD → SUBMIT ({"value":"something"}) (13ms)
  reaches state: "closed" 
    ✓ via CLICK_GOOD → CLOSE (6ms)
    ✓ via CLICK_BAD → SUBMIT ({"value":"something"}) → CLOSE (11ms)
    ✓ via CLICK_BAD → CLOSE (10ms)
    ✓ via CLOSE (4ms)
  reaches state: "form" 
    ✓ via CLICK_BAD (5ms)

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        2.834s

That's nine tests automatically generated with our finite state machine model of the app! Every single one of those tests asserts that the app is in the correct state and that the proper actions are executed (and validated) to transition to the next state at each step, and finally asserts that the app is in the correct target state.

These tests can quickly grow as your app gets more complex; for instance, if you add a back button to each screen or add some validation logic to the form page (please don't; be thankful the user is even going through the feedback form in the first place) or add a loading state submitting the form, the number of possible paths will increase.

Advantages of Model-Based Testing

Model-based testing greatly simplifies the creation of integration and E2E tests by autogenerating them based on a model (like a finite state machine), as demonstrated above. Since manually writing full tests is eliminated from the test creation process, adding or removing new features no longer becomes a test maintenance burden. The abstract model only needs to be updated, without touching any other part of the testing code.

For example, if you want to add the feature that the form shows up whether the user clicks the "Good" or "Bad" button, it's a one-line change in the finite state machine:

// ...
    question: {
      on: {
//      CLICK_GOOD: 'thanks',
        CLICK_GOOD: 'form',
        CLICK_BAD: 'form',
        CLOSE: 'closed',
        ESC: 'closed'
      },
      meta: {/* ... */}
    },
// ...

All tests that are affected by the new behavior will be updated. Test maintenance is reduced to maintaining the model, which saves time and prevents errors that can be made in manually updating tests. This has been shown to improve efficiency in both developing and testing production applications, especially as used at Microsoft on recent customer projects — when new features were added or changes made, the autogenerated tests gave immediate feedback on which parts of the app logic were affected, without needing to manually regression test various flows.

Additionally, since the model is abstract and not tied to implementation details, the exact same model, as well as most of the testing code, can be used to author E2E tests. The only things that would change are the tests for verifying the state and the execution of the actions. For example, if you were using Puppeteer, you can update the state machine:

// ...
question: {
  on: {
    CLICK_GOOD: 'thanks',
    CLICK_BAD: 'form',
    CLOSE: 'closed'
  },
  meta: {
    test: async (page) => {
      await page.waitFor('[data-testid="question-screen"]');
    }
  }
},
// ...
const testModel = createModel(/* ... */)
  .withEvents({
    CLICK_GOOD: async (page) => {
      const goodButton = await page.$('[data-testid="good-button"]');
      await goodButton.click();
    },
    // ...
  });

And then these tests can be run against a live Chromium browser instance:

End-to-end tests for Feedback app being run in a browser

The tests are autogenerated the same, and this cannot be overstated. Although it just seems like a fancy way to create DRY test code, it goes exponentially further than that — autogenerated tests can exhaustively represent paths that explore all the possible actions a user can do at all possible states in the app, which can readily expose edge-cases that you might not have even imagined.

The code for both the integration tests with @testing-library/react and the E2E tests with Puppeteer can be found in the the XState test demo repository.

Challenges to Model-Based Testing

Since model-based testing shifts the work from manually writing tests to manually writing models, there is a learning curve. Creating the model necessitates the understanding of finite state machines, and possibly even statecharts. Learning these are greatly beneficial for more reasons than just testing, since finite state machines are one of the core principles of computer science, and statecharts make state machines more flexible and scalable for complex app and software development. The World of Statecharts by Erik Mogensen is a great resource for understanding and learning how statecharts work.

Another issue is that the algorithm for traversing the finite state machine can generate exponentially many test paths. This can be considered a good problem to have, since every one of those paths represents a valid way that a user can potentially interact with an app. However, this can also be computationally expensive and result in semi-redundant tests that your team would rather skip to save testing time. There are also ways to limit these test paths, e.g., using shortest paths instead of simple paths, or by refactoring the model. Excessive tests can be a sign of an overly complex model (or even an overly complex app 😉).

Write Fewer Tests!

Modeling app behavior is not the easiest thing to do, but there are many benefits of representing your app as a declarative and abstract model, such as a finite state machine or a statechart. Even though the concept of model-based testing is over two decades old, it is still an evolving field. But with the above techniques, you can get started today and take advantage of generating integration and E2E tests instead of manually writing every single one of them.

More resources:

I gave a talk at React Rally 2019 demonstrating model-based testing in React apps:

Slides: Slides: Write Fewer Tests! From Automation to Autogeneration

Happy testing!

The post Model-Based Testing in React with State Machines appeared first on CSS-Tricks.

The Importance of One-on-Ones

What do we mean by 1:1 (pronounced one-on-one)? This is typically a private conversation between an Engineering Manager/Lead and their Employee. I personally have been a Lead, a Manager, and also an Independent Contributor/Software Engineer, so I’ve sat at each side of the table. I’ve both had great experiences on each side and have made mistakes on each side. That said, I'm going to cover some meditations on the subject because 1:1s open opportunities for personal and professional growth when they're effective.

What I’ve noticed about Software Engineering as a discipline, in particular, is that it has many people sharing posts about technical implementations and very few about engineering management. Management can influence and impact our ability to code efficiently and hone our craft, so it’s worth exploring publicly.

My thoughts on this change a lot and, like all humans, I’m always learning, so please don’t take any of these opinions as gospel. Think of them more like a dialogue where we can bounce ideas off one another.

Establishing baseline rules

I believe that 1:1s are crucial and should not be the kind of meeting anyone takes lightly, whether on the management or employee side. The meetings should have a regular cadence, scheduled either once a week or biweekly and only cancelled for pressing circumstances — and if they have to be cancelled, it's a good practice to let the other person know why rather than simply removing it from the calendar.

It might be tempting to think remote working means fewer 1:1s, but it's quite the opposite. Since each person is in a different space on a day-to-day basis, 1:1s help make up for sporadic contact by meeting regularly.

1:1s should be conducted in a space with the smallest amount of distractions possible. If you are in a room with one other person, shut off your computer and use a notepad so you won’t get notifications. If doing a 1:1 remotely, make sure you’re in a quiet place and that it has stable internet bandwidth. And, please, avoid taking 1:1s in a car or while running errands. It's also worth trying to limit the time you spend in noisy environments, like cafes. Another tip: if you have to be outside, wear headphones. Again, this is all for the benefit of limiting distractions so that everyone's focus is on the meeting itself.

Honestly, I would rather someone cancel on me or push the meeting off until they’re in a quiet place than take a call swarming with distractions. Nothing says, “I don’t value your time,” like multitasking during a 1:1 meeting. The whole purpose of the 1:1 should be to make the other person feel valuable and connected.

meeting between two people
📷 Credit: @rawpixel on Unsplash

So, why should we devote time to 1:1s anyway?

1:1s are crucial. If we constantly work on tasks without taking the time to step back and check in with our work, we risk being tactical rather than strategic. We risk working in a silo, which can lead to burnout and anxiety. We risk opportunities to spot errors early and reduce technical debt. At their root, 1:1s should reduce uncertainty by making us feel more connected to the rest of the team while clarifying intent.

For example, on the employee side, you might not be sure whether to invest your time in Task A or Task B and the progress of your commits slows down as a result. Which one is higher priority? On the manager side, you might not be sure what's happening — the employee could be stuck on a problem. They could be burnt out, but it's tough to be sure. It's totally normal for someone to get stuck once in a while, but it's common to not want to announce it in front of others, perhaps out of fear of embarrassment, among other things. A 1:1 is a good, safe, private place to explore concerns before they become tangible problems because they offer privacy that some open floor plans simply do not.

This privacy part is important. Candid exploration of high level topics, like career goals, or even low level topics, like code reviews, are best done and that is easier to do with one person in a private space rather than a full audience out in the open. At their best, 1:1s should create a good environment to resolve some of these issues.

Employees and managers alike should be fully invested in the meeting. This means using active body language that shows attention. This means emphasizing listening and speaking in turn without interrupting the other person.

Connection

Belonging is a core tenant of Maslow's hierarchy of needs because, as humans, we're designed for connectedness and kinship. I know this article is about engineering management, but engineers are no less in need of empathy and human connection than any other person in any other profession.

The reason I include this at all is because connecting with others on a personal level is something I really need to work on myself. I’m awkward. I’m an introvert. I don’t always know how to talk to people. But I do know that there have been plenty of 1:1s where I either felt heard or that I was hearing someone else. In other words, I felt in connected to the other person, be it through shared goals, personal similarities, or even common gripes about something.

A friend of mine mentioned that "people leave managers, not jobs." This is, for the most part, so true! Simply taking the time to develop a connection where a manager and employee both know each other better creates a higher level of comfort that can go a long way towards many benefits, including employee retention.

It might be worth asking the other person what modality works best if you're remote. Some people prefer video chats; some people prefer phone calls. That's all part of fostering a better connection.

1:1s are more for employees than managers

Don't let that headline give you pause. Yes, these meetings are for both parties. They really are. But here’s the thing: in the balance of power, the manager can always speak directly to the employee. The inverse isn’t always true. There are also dynamics between teammates. That means the manager’s job in a 1:1 is to provide a space for the employee to speak clearly and freely about concerns, particularly ones that might impact their performance.

Ideally, a manager will listen more than an employee, but a back and forth dialogue can be healthy, too. A 1:1 where a manager is speaking the most is probably the least productive. This isn't team time; it's time to give an employee the floor because it otherwise might not happen in other venues.

In my experience, it’s best if a manager first learns the an employee's Ultimate Goals™. Where do they see themselves in five years? What kind of work they like to do most? What environments do they work in best and which ones are the most difficult? A manager can’t always facilitate the ideal situation, but having this information is still extremely valuable for cultivating a person’s career trajectory, for the work that needs to be done, and for a general understanding of what will keep people working well together.

Let's say you have two employees: one wants to be a Principal Architect someday and another who tells you that they love refactoring. That actually gives you pretty good insight for a project that requires one person to drive direction and another to clean up the legacy code in preparation for the refactor!

Or, say you have an employee that wants to be Director someday but rarely helps others. You also concurrently get an intern. This is your chance to develop one's mentoring skills and scale the other's engineering skills.

When these meetings are focused on the employee instead of the manager, they help the employee feel heard and motivated, which can bolster their career and also give the manager the ability to make bigger decisions about how everyone works together to accomplish their individual and collective goals.

meeting between two people
📷 Credit: @rawpixel on Unsplash

Yes, agendas are required

Yes, even though 1:1s have a tendency to be informal because everyone already knows each other well, they’re way more successful when there's an agenda, at least in my opinion. And no, it’s not important for the agendas to be super formal either. They could be a couple bullet points on a sheet of paper. Or even items added to a private Slack channel. What's most important is that both parties come prepared to talk.

If both the manager and the employee have agendas, my preference is to either defer priority to the employee, or compare lists up front to prioritize items. It might be that the manager has to discuss something pressing and sensitive, like a team reorg that affects the employee's agenda. Regardless, communication is key. In a best-case scenario, you’re both in lock step and that all agenda items actually overlap.

Employees: Sometimes weeks are tough and it's easy to get frustrated. Taking time to write an agenda keeps the meeting from being all, “I hate everything and how could you have done me so wrong,” and more focused on actionable items. Why not just vent? Sure, there's a time and place for venting, but the problem with it is that your manager is a person, and might not know exactly how to help you on an emotional level. Having specific topics and items make it facilitate more actionable feedback for your manager, and therefore, make them better able to support you.

Managers: Let’s face it, you’re probably juggling a million plates. (That metaphor might be wrong, but you catch my drift.) There’s a lot on your mind and most of it is confidential. Agenda give you the context you need to prevent wandering into topics you might not be at liberty to discuss. It also keeps things on track. Are there four more things you need to cover and you’re already 15 minutes into a 30-minute meeting? You’re less likely to pontificate about your early career or foray into irrelevant paths and stay focused on the task and human right in front of you.

Direction and Guidance

One thing that a 1:1 can be useful for is guidance. On a few occasions, I’ve checked in with an employee who's communicated feeling like they’re in over their heads — whether they've overcommitted or have such a tall task in front of them, they’re not sure how to proceed and feel anxious to the point of paralysis.

As mentioned before, this is a great opportunity for a manager to reduce uncertainty. Some ways to do that:

  • Prioritize. If there’s too much work, spend time talking through the most important pieces, and even perhaps offer yourself as a shield from some of the work.
  • Make action items. Sometimes a task is too large and the employee needs help breaking it down into organized pieces making it easier to know where to start and how to move forward.
  • Clarify vision. People might feel overwhelmed because they don’t know why they’re doing something. If you can communicate the necessity of the work at hand, then it can align them with the goal of the project and make the work more rewarding and valuable.

One risk here is passive listening. For example, there's a fine line between knowing when to let an employee vent and when that venting needs actionable solutions. Or both! I have no hard rules about when one is needed over the other, and I sometimes get this wrong myself. This is why eye contact and active listening is important. You’ll receive subtle cues from the person that help reveal what is needed in the situation.

If you’re an employee and your manager isn’t providing the listening mode you need from them, I think it’s OK to gently mention that. Your manager isn’t a mind reader, and in many cases, they haven’t even received management training to develop proper listening skills. It’s perfectly fine to say something along the lines of, "It would be really great if you could sit with me and help me prioritize all these tasks on my to do list,” or “I really need to vent right now, but some of the venting is stuff I think is valuable for you to know about." Personally, I love it when someone tells me what they need. I’m usually trying to figure that out, so it takes out the guesswork.

Meeting adjourned...

You spend many waking hours at work. It’s important that your working relationships — particularly between manager and employee — are healthy and that you're intentionally checking in with purpose, both in the short-term and the long-term.

1:1s may appear to be time hogs on the calendar, but over the long haul, you’ll find they save valuable time. As a manager, having a team of employees who feel valued, aligned and connected is about the best thing you can ask for. So, value them because you'll get solid value in return.

More Resources