Hajime, the duck guy

Monday, February 3, 2025, by Hajime Yamasaki Vukelic

About this series

Step-by-step guide for building applications using nothing but browser APIs – no build, no tools, no frameworks

  1. Introduction to vanilla, goals, principles
  2. Requirements, architecture, content structure
  3. SVG spritesheet for icons
  4. Data molding, HTTP requests, basic event handling
  5. Storage, advanced event handling
  6. MPA, teamwork and shared code, separation of concerns
  7. Webfonts, asset preloading, responsive grid layouts
  8. Responsive tables using a grid layout with subgrids
  9. Toolless lazy loading and micro-frontends (part 1)
  10. Toolless lazy loading and micro-frontends (part 2)
  11. Reverting back to classic MPA and comparison with micro-frontend
  12. Code cleanup and vanilla CSS-only view transitions
  13. Accessible tabbed interface and a simple feature flag
  14. Visualization using vanilla SVG (part 1)
  15. Visualization using vanilla SVG (part 2)
  16. Visualization using vanilla SVG (part 3)
  17. PWA and offline mode

Reverting back to classic MPA and comparison with micro-frontend

In the last part, I've finished the modular version of the weather page, and described the technique, advantages and disadvantages of such a design. In this part, I'm going to revert all that back into the more traditional MPA style where the pages are units.

In the end, I'm going to highlight the differences between the two styles and give you some guidelines I follow to decide when to use which.

The starting point

I'm going to rewind back to where we were at before I did the modular design – back to the end of the part 8. In that part, the markup was already done (though requiring some adjustments later) and CSS was almost done (also requiring some adjustments later). For this part, assume we're starting from that state.

I'm going to skip all the adjustments. You can read about them in the previous parts if you need a refresher.

In short, we have the HTML in index.html, looking something like this:

<main>
    <x-weather-summary>
        <h2 class="alt-text">Current weather</h2>

        <dl class="weather-tiles">
            <div class="current-temperature">
                <dt>
                    <span class="alt-text">Current weather and temperature</span>
                </dt>
                <!-- ... -->
            </div>
            <!-- ... -->
        </dl>
        <!-- ... -->
    </x-weather-summary>
    
    <x-weather-forecast>
        <h2 class="alt-text">10-day forecast</h2>

        <table class="forecast">
            <thead class="alt-text">
                <tr>
                    <th scope="column">Date</th>
                    <th scope="column">Weather and maximum temperature</th>
                    <th scope="column">Minimum temperature</th>
                    <th scope="column">Precipitation</th>
                    <th scope="column">Wind</th>
                </tr>
            </thead>
            <!-- .... -->
        </table>
    </x-weather-forecast>
</main>

In the previous two parts, we extracted the CSS into two separate files and tweaked it. In this part, that that CSS is still in one file, index.css.

The next step was JavaScript.

Adding back the behavior

As before, I'm starting with the summary section. This goes more or less the same as in the previous parts. Start with the custom element, do one pass, do another pass, etc., etc.

customElements.define('x-weather-summary', class extends CustomElement {
    setup() {
        let selectedPlace = JSON.parse(localStorage.selectedPlace || null),
            lat = selectedPlace?.lat,
            long = selectedPlace?.long,
            timezone = selectedPlace?.timezone

        let currentTemperature = this.querySelector('.current-temperature dd span'),
            weatherConditionIcon = this.querySelector('.current-weather-condition'),
            minTemperature = this.querySelector('.min-temperature dd'),
            maxTemperature = this.querySelector('.max-temperature dd'),
            precipitation = this.querySelector('.precipitation dd'),
            pressure = this.querySelector('.pressure dd'),
            humidity = this.querySelector('.humidity dd'),
            windSpeed = this.querySelector('.wind-speed'),
            windDirectionLabel = this.querySelector('.wind-direction-label'),
            windDirectionGraphic = this.querySelector('.wind-direction-graphic'),
            noValue = this.querySelector('template[data-name="no-value"]').content

        let COMPASS_DIRECTIONS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']

        let getWeatherId = code => { /* .... */ },
            getCompassDirection = degrees => COMPASS_DIRECTIONS[Math.round(degrees / 22.5) % 16],
            displayError = () => { /* .... */ },
            displaySummary = data => { /* .... */ }

        if (selectedPlace) {
            let http = new XMLHttpRequest()
            http.open('GET', `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${long}&timezone=${encodeURIComponent(timezone)}&current=temperature_2m,relative_humidity_2m,weather_code,surface_pressure,wind_speed_10m,wind_direction_10m&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=1`)
            http.responseType = 'json'
            http.onerror = displayError
            http.onload = () => {
                if (http.status == 200) displaySummary(http.response)
                else displayError()
            }
            http.send()
        }
    }
})

Now the interesting part. I'm implementing the forecast. This time I can start factoring things out as I notice patterns. I don't have to worry about whether I will need another module or not, because it's all happening within the same file. I'm going to skip over the details and only highlight the things I factor out.

First the easy bit. The selected location is common to not just the weather forecast and summary, but also the place label in the header. This line moves to the module scope:

let selectedPlace = JSON.parse(localStorage.selectedPlace || null)

Now let me touch real-quick on the issue of selectedPlace being a 'global variable' (it's technically a module-scoped variable, but that's just a technicality). If this gives you a knee-jerk reaction, here's a few reasons why you might want to reconsider:

  • This variable represents a page-wide concept, so the scope being module-wide is appropriate.
  • The variable and the information it contains isn't updated by any code on the page (we have a dedicated page for that), so it has the same status as other global truths such as the URL of the page, or current time.
  • From a strictly pragmatic point of view, it's simpler to just have it out there in the open than to jump through hoops to make a global concept local somehow. (You should still try, though, just to learn why it's a bad idea.)

The variable name was wrong in the <x-current-place> custom element, but otherwise it's just a matter or removing the matching variable declaration from the respective custom elements. (And to be frank, I love refactoring within the same file. It's so much easier. Even if I plan on extracting something to a different module, I'd first do it within the same file, and only move to a different file after I'm done separating things.)

The next bit of code is related to making the HTTP requests. Let's first take a peek at the code I'm referring to:

// In summary:
let http = new XMLHttpRequest()
http.open('GET', `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${long}&timezone=${encodeURIComponent(timezone)}&current=temperature_2m,relative_humidity_2m,weather_code,surface_pressure,wind_speed_10m,wind_direction_10m&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=1`)
http.responseType = 'json'
http.onerror = displayError
http.onload = () => {
    if (http.status == 200) displaySummary(http.response)
    else displayError()
}
http.send()

// In forecast:
http.open('GET', `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${long}&timezone=${encodeURIComponent(timezone)}&start_date=${localDateISO.format(startDate)}&end_date=${localDateISO.format(endDate)}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max,wind_direction_10m_dominant`)
http.responseType = 'json'
http.onerror = displayError
http.onload = () => {
    if (http.status == 200) displayForecast(http.response)
    else displayError()
}
http.send()

I noted in the previous parts two things:

  1. These blocks are pretty much identical.
  2. These two requests can be collapsed into one.

The reason I didn't collapse them into one before is that I was trying to simulate a situation where two teams work no the page in isolation. It felt more natural for them to not collapse those two calls as they may not be aware of the overlap and/or may have wanted more control over their respective calls, or to reduce complexity. For example, if one team wants to change something in their call, it would be inconvenient to have to first check with the other team, and resolve possible conflicts. Another example would be the implementation itself. If the API calls were shared, then they would need to live in a shared module (e.g., index.js), introducing a layer of coordination between teams. Even if the shared module is well-structured, the real complexity comes from the human factor – negotiating changes, ensuring compatibility, and avoiding release bottlenecks. Keeping the calls separate may lead to some duplication, but it also allows each team to iterate more freely without dependencies slowing them down.

This time around, I have no such complication. All code is under my control, so I can organize it based purely on the user or business needs – such as collapsing the two calls for performance and/or a more efficient use of server resources. This does create an explicit dependency between the summary and forecast elements, but since there's no need to coordinate with other teams, conflicts are easy to anticipate and resolve. The complexity here is technical, not organizational.

The function looks like this:

let cachedData = null,
    getWeatherData = (handleError, handleSuccess) => {
      if (!cachedData) {
        let url = new URL('https://api.open-meteo.com/v1/forecast'),
            params = url.searchParams,
            http = new XMLHttpRequest()

        params.set('latitude', selectedPlace.lat)
        params.set('longitude', selectedPlace.long)
        params.set('timezone', selectedPlace.timezone)
        params.set('forecast_days', 11)
        params.set('current', 'temperature_2m,relative_humidity_2m,weather_code,surface_pressure,wind_speed_10m,wind_direction_10m')
        params.set('daily', 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max,wind_direction_10m_dominant')

        cachedData = fetch(url).then(res => {
          if (res.ok) return res.json()
          else throw Error('bad response')
        })
      }

      cachedData.then(handleSuccess, handleError)
    }

The part in the middle which deals with the params object is added for clarity. Rather than using string interpolation, I use the URLSearchParams API to make it a bit easier to see which parameters are being used.

I'm using a Promise to represent an ongoing or finished API call. I can call the .then() method on the promise any number of times, and if it's already resolved, it will just yield that resolved value every time. It's effectively a cache. Therefore, the entire getWeatherData() function is cached.

Since I'm using a Promise, I've switched from XMLHttpRequest to fetch(). The fetch() call is more natural here as it returns a Promise, and saves me from having to do the conversion manually.

The usage looks like this:

// In summary
if (selectedPlace) getWeatherData(displayError, displaySummary)

// In forecast
if (selectedPlace) getWeatherData(displayError, displayForecast)

Nice and neat.

Lastly, I'm extracting the shared formatting functions:

let COMPASS_DIRECTIONS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'],
    getCompassDirection = degrees => COMPASS_DIRECTIONS[Math.round(degrees / 22.5) % 16],
    getWeatherId = code => {
        // ....
    }

Why have I not extracted all formatting functions here? There's two ways to look at it:

  1. If I extract all formatting function here, into the module-scope, grouped together, it becomes easier to tell where to find formatting functions.
  2. If I don't extract formatting functions, it becomes easier to tell which ones are shared and which aren't (that is what the impact of changing them is going to be).

If I cared enough to solve both of these issues, I could leverage a naming convention – I could name all formatting functions format*(). This way, I'd be making them easier to discover, and would also keep the ability to tell whether they are shared or not.

I don't want to go crazy with refactoring, though. This is about as much refactoring as I need for now.

Here's the new version of the code.

Comparison

Let's compare the two versions. If you missed it, here's the older version of the code as well, for a side-by-side.

Let me first highlight the unique technical features of the two solutions:

The modular solution had:

  • A unit loader that loaded HTML, CSS, and JavaScript for each unit
  • Separate units – basically mini apps – that could be developed more or less independently

The monolithic solution had:

  • All code in a single module (index.js)
  • Code organization that uses natural constructs like classes rather than physical files

The results are as follows.

The modular solution has:

  • Lower technical complexity within the units
  • Higher overall technical complexity
  • Higher organizational/cognitive complexity overall

The monolithic solution gives us:

  • Lower overall technical complexity
  • Lower organizational/cognitive complexity overall

These two approaches are not mutually exclusive. As you could see in the previous parts, you can have some pages as monolithic (place selection page) and some as modular (weather information page). Which approach I use depends on the teams' needs.

To scale a project successfully, what I generally want is to minimize the coordination complexity. This means the following:

  • Expand individual/team ownership over the code.
  • Avoid over-compartmentalizing the code.
  • Have simple contracts between units.

These are the parameters that drive the decisions about code modularization. I would generally lean towards prioritizing scope of ownership and minimizing unnecessary barriers between different parts of the code. Modularization isn't something I introduce by default, it's a response to real-world team needs. When different teams genuinely need to own different parts of a page, I modularize after reviewing the contracts between the units (and teams).

For kicks, let's also take a look at the payload size differences. In the case of the modular version, I've got 12.3KB of JavaScript across 4 requests. In the case of the monolithic version, I've got 9KB of JavaScript across 2 requests. This example is relatively small, so the findings aren't conclusive (yet), but it matches my general experience with larger apps. Now you see why I default to the monolith unless there are overriding reasons. It also shows that depending on how you approach 'vanilla' development, you can have more or less code – begin vanilla doesn't guarantee lean codebases.

Conclusion

With this, I conclude the MVP version. It's the most minimal version that you can legitimately call a weather app.

However, the app doesn't really have too many realistic app-like features. Starting with the next part, I'm going to expand the app with additional features and eventually make it a far more app-like experience.

Posted in Programming tips
Back to top