Build Design Systems With Penpot Components
Penpot's new component system for building scalable design systems, emphasizing designer-developer collaboration.

medium bookmark / Raindrop.io |
Recently I had the experience of reviewing a project and accessing its scalability and maintainability. There were a few bad practices here and there, a few strange pieces of code with lack of meaningful comments. Nothing uncommon for a relatively big (legacy) codebase, right?
However, there is something that I keep finding. A pattern that repeated itself throughout this codebase and a number of other projects I’ve looked through. They could be all summarized by lack of abstraction. Ultimately, this was the cause for maintenance difficulty.
In object-oriented programming, abstraction is one of the three central principles (along with encapsulation and inheritance). Abstraction is valuable for two key reasons:
The lack of abstraction inevitably leads to problems with maintainability.
Often I’ve seen colleagues that want to take a step further towards more maintainable code, but they struggle to figure out and implement fundamental abstractions. Therefore, in this article, I’ll share a few useful abstractions I use for the most common thing in the web world: working with remote data.
It’s important to mention that, just like everything in the JavaScript world, there are tons of ways and different approaches how to implement a similar concept. I’ll share my approach, but feel free to upgrade it or to tweak it based on your own needs. Or even better – improve it and share it in the comments below! ❤️
I haven’t had a project which doesn’t use an external API to receive and send data in a while. That’s usually one of the first and fundamental abstractions I define. I try to store as much API related configuration and settings there like:
const API = {
url: 'http://httpstat.us/',
_handleError(_res) {
return _res.ok ? _res : Promise.reject(_res.statusText);
},
get(_endpoint) {
return window.fetch(this.url + _endpoint, {
method: 'GET',
headers: new Headers({
'Accept': 'application/json'
})
})
.then(this._handleError)
.catch( error => { throw new Error(error) });
},
post(_endpoint, _body) {
return window.fetch(this.url + _endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: _body,
})
.then(this._handleError)
.catch( error => { throw new Error(error) });
}
};
In this module, we have 2 public methods, get()
and post()
which both return a Promise. On all places where we need to work with remote data, instead of directly calling the Fetch API via window.fetch()
, we use our API module abstraction – API.get()
or API.post()
.
Therefore, the Fetch API is not tightly coupled with our code.
Let’s say down the road we read Zell Liew’s comprehensive summary of using Fetch and we realize that our error handling is not really advanced, like it could be. We want to check the content type before we process with our logic any further. No problem. We modify only our APP
module, the public methods API.get()
and API.post()
we use everywhere else works just fine.
const API = {
_handleContentType(_response) {
const contentType = _response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return _response.json();
}
return Promise.reject('Oops, we haven\'t got JSON!');
},
get(_endpoint) {
return window.fetch(this.url + _endpoint, {
method: 'GET',
headers: new Headers({
'Accept': 'application/json'
})
})
.then(this._handleError)
.then(this._handleContentType)
.catch( error => { throw new Error(error) })
},
post(_endpoint, _body) {
return window.fetch(this.url + _endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: _body
})
.then(this._handleError)
.then(this._handleContentType)
.catch( error => { throw new Error(error) })
}
};
Let’s say we decide to switch to zlFetch, the library which Zell introduces that abstracts away the handling of the response (so you can skip ahead to and handle both your data and errors without worrying about the response). As long as our public methods return a Promise, no problem:
import zlFetch from 'zl-fetch';
const API = {
get(_endpoint) {
return zlFetch(this.url + _endpoint, {
method: 'GET'
})
.catch( error => { throw new Error(error) })
},
post(_endpoint, _body) {
return zlFetch(this.url + _endpoint, {
method: 'post',
body: _body
})
.catch( error => { throw new Error(error) });
}
};
Let’s say down the road due to whatever reason we decide to switch to jQuery Ajax for working with remote data. Not a huge deal once again, as long as our public methods return a Promise. The jqXHR objects returned by $.ajax()
as of jQuery 1.5 implement the Promise interface, giving them all the properties, methods, and behavior of a Promise.
const API = {
get(_endpoint) {
return $.ajax({
method: 'GET',
url: this.url + _endpoint
});
},
post(_endpoint, _body) {
return $.ajax({
method: 'POST',
url: this.url + _endpoint,
data: _body
});
}
};
But even if jQuery’s $.ajax()
didn’t return a Promise, you can always wrap anything in a new Promise(). All good. Maintainability++!
Now let’s abstract away the receiving and storing of the data locally.
Let’s assume we need to take the current weather. API returns us the temperature, feels-like, wind speed (m/s), pressure (hPa) and humidity (%). A common pattern, in order for the JSON response to be as slim as possible, attributes are compressed up to the first letter. So here’s what we receive from the server:
{
"t": 30,
"f": 32,
"w": 6.7,
"p": 1012,
"h": 38
}
We could go ahead and use API.get('weather').t
and API.get('weather').w
wherever we need it, but that doesn’t look semantically awesome. I’m not a fan of the one-letter-not-much-context naming.
Additionally, let’s say we don’t use the humidity (h
) and the feels like temperature (f
) anywhere. We don’t need them. Actually, the server might return us a lot of other information, but we might want to use only a couple of parameters only. Not restricting what our weather module actually needs (stores) could grow to a big overhead.
Enter repository-ish pattern abstraction!
import API from './api.js';
const WeatherRepository = {
_normalizeData(currentWeather) {
const { t, w, p } = currentWeather;
return {
temperature: t,
windspeed: w,
pressure: p
};
},
get(){
return API.get('/weather')
.then(this._normalizeData);
}
}
Now throughout our codebase use WeatherRepository.get()
and access meaningful attributes like .temperature
and .windspeed
. Better!
Additionally, via the _normalizeData()
we expose only parameters we need.
There is one more big benefit. Imagine we need to wire-up our app with another weather API. Surprise, surprise, this one’s response attributes names are different:
{
"temp": 30,
"feels": 32,
"wind": 6.7,
"press": 1012,
"hum": 38
}
No worries! Having our WeatherRepository
abstraction all we need to tweak is the _normalizeData()
method! Not a single other module (or file).
const WeatherRepository = {
_normalizeData(currentWeather) {
const { temp, wind, press } = currentWeather;
return {
temperature: temp,
windspeed: wind,
pressure: press
};
},
};
The attribute names of the API response object are not tightly coupled with our codebase. Maintainability++!
Down the road, say we want to display the cached weather info if the currently fetched data is not older than 15 minutes. So, we choose to use localStorage
to store the weather info, instead of doing an actual network request and calling the API each time WeatherRepository.get()
is referenced.
As long as WeatherRepository.get()
returns a Promise, we don’t need to change the implementation in any other module. All other modules which want to access the current weather don’t (and shouldn’t) care how the data is retrieved – if it comes from the local storage, from an API request, via Fetch API or via jQuery’s $.ajax()
. That’s irrelevant. They only care to receive it in the “agreed” format they implemented – a Promise which wraps the actual weather data.
So, we introduce two “private” methods _isDataUpToDate()
– to check if our data is older than 15 minutes or not and _storeData()
to simply store out data in the browser storage.
const WeatherRepository = {
_isDataUpToDate(_localStore) {
const isDataMissing =
_localStore === null || Object.keys(_localStore.data).length === 0;
if (isDataMissing) {
return false;
}
const { lastFetched } = _localStore;
const outOfDateAfter = 15 * 1000;
const isDataUpToDate =
(new Date().valueOf() - lastFetched) < outOfDateAfter;
return isDataUpToDate;
},
_storeData(_weather) {
window.localStorage.setItem('weather', JSON.stringify({
lastFetched: new Date().valueOf(),
data: _weather
}));
},
get(){
const localData = JSON.parse( window.localStorage.getItem('weather') );
if (this._isDataUpToDate(localData)) {
return new Promise(_resolve => _resolve(localData));
}
return API.get('/weather')
.then(this._normalizeData)
.then(this._storeData);
}
};
Finally, we tweak the get()
method: in case the weather data is up to date, we wrap it in a Promise and we return it. Otherwise – we issue an API call. Awesome!
There could be other use-cases, but I hope you got the idea. If a change requires you to tweak only one module – that’s excellent! You designed the implementation in a maintainable way!
If you decide to use this repository-ish pattern, you might notice that it leads to some code and logic duplication, because all data repositories (entities) you define in your project will probably have methods like _isDataUpToDate()
, _normalizeData()
, _storeData()
and so on…
Since I use it heavily in my projects, I decided to create a library around this pattern that does exactly what I described in this article, and more!
SuperRepo is a library that helps you implement best practices for working with and storing data on the client-side.
const WeatherRepository = new SuperRepo({
storage: 'LOCAL_STORAGE',
name: 'weather',
outOfDateAfter: 5 * 60 * 1000,
request: () => API.get('weather'),
dataModel: {
temperature: 't',
windspeed: 'w',
pressure: 'p'
}
});
WeatherRepository.getData().then( data => {
console.log(`It is ${data.temperature} degrees`);
});
The library does the same things we implemented before:
_normalizeData()
, the dataModel
option applies a mapping to our rough data. This means:
.temperature
and .windspeed
instead of .t
and .s
.Plus, a few additional improvements:
WeatherRepository.getData()
is called multiple times from different parts of our app, only 1 server request is triggered.localStorage
, in the browser storage (if you’re building a browser extension), or in a local variable (if you don’t want to store data across browser sessions). See the options for the storage
setting.WeatherRepository.initSyncer()
. This will initiate a setInterval, which will countdown to the point when the data is out of date (based on the outOfDateAfter
value) and will trigger a server request to get fresh data. Sweet.To use SuperRepo, install (or simply download) it with NPM or Bower:
npm install --save super-repo
Then, import it into your code via one of the 3 methods available:
<script src="/node_modules/super-repo/src/index.js"></script>
import SuperRepo from 'super-repo';
const SuperRepo = require('super-repo');
And finally, define your SuperRepositories 🙂
For advanced usage, read the documentation I wrote. Examples included!
The abstractions I described above could be one fundamental part of the architecture and software design of your app. As your experience grows, try to think about and apply similar concepts not only when working with remote data, but in other cases where they make sense, too.
When implementing a feature, always try to discuss change resilience, maintainability, and scalability with your team. Future you will thank you for that!
AI-driven updates, curated by humans and hand-edited for the Prototypr community